diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2187cab4a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "contracts/monerium-forwarder/lib/forge-std"] + path = contracts/monerium-forwarder/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index 303ba342e..65da6ab6b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ bun install If you encounter issues with the `bun install` command, you can try upgrading your `bun` version with `bun upgrade`. The installation is confirmed to work in bun v1.3.1. +The Foundry-based contracts in `contracts/monerium-forwarder` use a git submodule (`forge-std`). If you plan to work on those contracts, initialize it once: + +```bash +git submodule update --init +``` + +(Or clone with `git clone --recurse-submodules`.) Everything else in the monorepo works without this step. + ### Running the Projects #### Run All Projects diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts new file mode 100644 index 000000000..ed39f2a5f --- /dev/null +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -0,0 +1,51 @@ +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { config } from "../../config/vars"; +import { APIError } from "../errors/api-error"; +import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { + deriveEventId, + MONERIUM_SIGNATURE_HEADER, + recordWebhookEvent, + verifyWebhookSignature +} from "../services/monerium-b2b/webhook"; + +/** + * POST /v1/monerium-b2b/webhook — durable-inbox webhook receiver (plan §3, R06). + * Order of operations is load-bearing: HMAC over the RAW bytes first, then persist the + * delivery (dedup on event id), and only then 200. Processing happens asynchronously + * after the response — Monerium retries are absorbed by the inbox dedup. + */ +export const handleWebhook = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const secret = config.moneriumB2b.webhookSecret; + if (!secret) { + throw new APIError({ message: "Monerium B2B webhook secret is not configured", status: httpStatus.SERVICE_UNAVAILABLE }); + } + + // Raw bytes captured by the body-parser verify hook in config/express.ts. + const rawBody = (req as Request & { rawBody?: Buffer }).rawBody; + if (!rawBody || !verifyWebhookSignature(rawBody, req.header(MONERIUM_SIGNATURE_HEADER), secret)) { + throw new APIError({ message: "Invalid webhook signature", status: httpStatus.UNAUTHORIZED }); + } + + let payload: unknown; + try { + payload = JSON.parse(rawBody.toString("utf8")); + } catch { + throw new APIError({ message: "Webhook payload is not valid JSON", status: httpStatus.BAD_REQUEST }); + } + + await recordWebhookEvent(deriveEventId(rawBody, payload), payload); + res.status(httpStatus.OK).json({ received: true }); + + setImmediate(() => { + processMoneriumWebhookInbox().catch(error => { + logger.error("monerium-b2b: async webhook inbox processing failed:", error); + }); + }); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 91f21bc09..99c7c4954 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -16,6 +16,7 @@ import fiatRoutes from "./fiat.route"; import maintenanceRoutes from "./maintenance.route"; import metricsRoutes from "./metrics.route"; import moneriumRoutes from "./monerium.route"; +import moneriumB2bRoutes from "./monerium-b2b.route"; import mykoboRoutes from "./mykobo.route"; import notificationsRoutes from "./notifications.route"; import onboardingRoutes from "./onboarding.route"; @@ -161,6 +162,12 @@ router.use("/mykobo", mykoboRoutes); */ router.use("/monerium", moneriumRoutes); +/** + * Monerium B2B whitelabel onramp. + * POST /v1/monerium-b2b/webhook — HMAC-authenticated durable-inbox webhook receiver. + */ +router.use("/monerium-b2b", moneriumB2bRoutes); + /** * POST v1/webhook * DELETE v1/webhook diff --git a/apps/api/src/api/routes/v1/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/monerium-b2b.route.ts new file mode 100644 index 000000000..8427f5a56 --- /dev/null +++ b/apps/api/src/api/routes/v1/monerium-b2b.route.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import * as moneriumB2bController from "../../controllers/monerium-b2b.controller"; + +const router = Router(); + +// Authenticated by HMAC signature over the raw body (no session/API-key auth). +router.post("/webhook", moneriumB2bController.handleWebhook); + +export default router; diff --git a/apps/api/src/api/services/monerium-b2b/attestor.test.ts b/apps/api/src/api/services/monerium-b2b/attestor.test.ts new file mode 100644 index 000000000..3f8a6801a --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/attestor.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Address, encodePacked, hashMessage, Hex, hexToBigInt, hexToNumber, keccak256, recoverAddress, slice } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { config } from "../../../config/vars"; +import { attestationBoundHash, attestorAddress, LINK_MESSAGE, linkMessageHash, signLinkAttestation } from "./attestor"; + +// Well-known test key (Foundry/Anvil account #0) — never a real attestor key. +const TEST_KEY: Hex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const FORWARDER: Address = "0x1111111111111111111111111111111111111111"; +const CHAIN_ID = 11155111n; // Sepolia, matching the G0 sandbox validation +// Malleability bound from VortexForwarder.isValidSignature (secp256k1 n/2). +const HALF_ORDER = hexToBigInt("0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0"); + +let originalKey: string | undefined; + +beforeAll(() => { + originalKey = config.moneriumB2b.attestorPrivateKey; + config.moneriumB2b.attestorPrivateKey = TEST_KEY; +}); + +afterAll(() => { + config.moneriumB2b.attestorPrivateKey = originalKey; +}); + +describe("monerium-b2b attestor", () => { + it("derives LINK_HASH_191 exactly as the forwarder immutable", () => { + // The Solidity constant hardcodes "\x19Ethereum Signed Message:\n45" — LINK_MESSAGE must be 45 bytes. + expect(Buffer.byteLength(LINK_MESSAGE, "utf8")).toBe(45); + // Independent derivation via viem's EIP-191 implementation. + expect(linkMessageHash()).toBe(hashMessage(LINK_MESSAGE)); + }); + + it("binds the signature to chainid + forwarder address exactly like isValidSignature", async () => { + const attestation = await signLinkAttestation(CHAIN_ID, FORWARDER); + // bound = keccak256(abi.encodePacked(block.chainid, address(this), hash)) — contract-side recomputation. + const expectedBound = keccak256( + encodePacked(["uint256", "address", "bytes32"], [CHAIN_ID, FORWARDER, hashMessage(LINK_MESSAGE)]) + ); + expect(attestation.boundHash).toBe(expectedBound); + expect(attestation.linkHash).toBe(hashMessage(LINK_MESSAGE)); + expect(attestation.message).toBe(LINK_MESSAGE); + // ecrecover(bound, v, r, s) must yield the ATTESTOR. + const signer = await recoverAddress({ hash: expectedBound, signature: attestation.signature }); + expect(signer).toBe(privateKeyToAccount(TEST_KEY).address); + expect(signer).toBe(attestorAddress()); + }); + + it("emits a 65-byte low-s signature with v in 27/28", async () => { + const { boundHash, linkHash, signature } = await signLinkAttestation(CHAIN_ID, FORWARDER); + expect(signature.length).toBe(2 + 65 * 2); + const v = hexToNumber(slice(signature, 64, 65)); + expect([27, 28]).toContain(v); + const s = hexToBigInt(slice(signature, 32, 64)); + expect(s <= HALF_ORDER).toBe(true); + expect(boundHash).toBe(attestationBoundHash(CHAIN_ID, FORWARDER, linkHash)); + expect(await recoverAddress({ hash: boundHash, signature })).toBe(privateKeyToAccount(TEST_KEY).address); + }); + + it("does not verify against a different forwarder address or chain", async () => { + const { signature } = await signLinkAttestation(CHAIN_ID, FORWARDER); + const otherAddress = attestationBoundHash(CHAIN_ID, "0x2222222222222222222222222222222222222222", linkMessageHash()); + expect(await recoverAddress({ hash: otherAddress, signature })).not.toBe(privateKeyToAccount(TEST_KEY).address); + // Cross-chain replay: same address, different chainid must not recover the attestor. + const otherChain = attestationBoundHash(1n, FORWARDER, linkMessageHash()); + expect(await recoverAddress({ hash: otherChain, signature })).not.toBe(privateKeyToAccount(TEST_KEY).address); + }); + + it("refuses to sign when the attestor key is not configured", async () => { + config.moneriumB2b.attestorPrivateKey = undefined; + try { + await expect(signLinkAttestation(CHAIN_ID, FORWARDER)).rejects.toThrow("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY"); + } finally { + config.moneriumB2b.attestorPrivateKey = TEST_KEY; + } + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/attestor.ts b/apps/api/src/api/services/monerium-b2b/attestor.ts new file mode 100644 index 000000000..8ffe1fcf6 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/attestor.ts @@ -0,0 +1,71 @@ +import { Address, encodePacked, Hex, keccak256, serializeSignature, stringToBytes } from "viem"; +import { privateKeyToAccount, sign } from "viem/accounts"; +import { config } from "../../../config/vars"; + +/** + * Attestor signature construction for VortexForwarder address linking. + * + * Mirrors contracts/monerium-forwarder/src/VortexForwarder.sol `isValidSignature`: + * the forwarder returns the EIP-1271 magic value iff the presented hash is the EIP-191 + * link hash (the raw-keccak variant was removed after the G0 sandbox validation on + * 2026-07-17 confirmed Monerium presents EIP-191) and the 65-byte (r,s,v; v in 27/28; + * low-s) signature recovers the ATTESTOR over + * `keccak256(abi.encodePacked(block.chainid, address(this), hash))` — chainid is part + * of the binding to prevent cross-chain replay (review r1 P2). + * + * The attestor key authorizes NOTHING beyond this fixed link statement — it can never + * move funds (security-spec/05-integrations/monerium-b2b.md). + */ + +export const LINK_MESSAGE = "I hereby declare that I am the address owner."; + +export interface LinkAttestation { + boundHash: Hex; + linkHash: Hex; + message: string; + signature: Hex; +} + +/** LINK_HASH_191 from the forwarder immutables (EIP-191 personal-message hash). */ +export function linkMessageHash(): Hex { + // hashMessage would work too; spelled out to match the Solidity constant byte for byte + // (LINK_MESSAGE is 45 bytes, hence the fixed "\x19Ethereum Signed Message:\n45"). + return keccak256(stringToBytes(`\x19Ethereum Signed Message:\n45${LINK_MESSAGE}`)); +} + +/** `bound = keccak256(abi.encodePacked(block.chainid, forwarderAddress, hash))`. */ +export function attestationBoundHash(chainId: bigint, forwarderAddress: Address, hash: Hex): Hex { + return keccak256(encodePacked(["uint256", "address", "bytes32"], [chainId, forwarderAddress, hash])); +} + +function attestorPrivateKey(): Hex { + const key = config.moneriumB2b.attestorPrivateKey; + if (!key) { + // Never include key material in errors or logs. + throw new Error("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY is not configured"); + } + return key as Hex; +} + +export function attestorAddress(): Address { + return privateKeyToAccount(attestorPrivateKey()).address; +} + +/** + * Builds the attestor signature submitted with Monerium's POST /addresses link call. + * Signs the bound digest directly (no extra EIP-191 prefix — the contract ecrecovers + * the bound hash as-is). viem's signer emits canonical low-s signatures, so the + * forwarder's malleability check passes. + */ +export async function signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise { + const linkHash = linkMessageHash(); + const boundHash = attestationBoundHash(chainId, forwarderAddress, linkHash); + const signature = await sign({ hash: boundHash, privateKey: attestorPrivateKey() }); + return { + boundHash, + linkHash, + message: LINK_MESSAGE, + // 65-byte r ‖ s ‖ v serialization with v in 27/28, as isValidSignature expects. + signature: serializeSignature(signature) + }; +} diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts new file mode 100644 index 000000000..6a9c789bd --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -0,0 +1,218 @@ +import { + Account, + Address, + createPublicClient, + createWalletClient, + Hex, + http, + PublicClient, + parseAbiItem, + Transport, + WalletClient +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; + +/** + * viem clients + minimal hand-written ABI surface for the B2B keeper + * (docs/prd/monerium-b2b-implementation-plan.md §3, "Keeper"). + * + * Key separation is an invariant (security-spec/05-integrations/monerium-b2b.md): + * keeper key (swap submission) != guardian key (protective pause) != attestor key + * (address linking). Reads go through the public RPC; keeper/guardian WRITES go + * through a separate submission transport for private orderflow. + */ + +/** Suggested private-orderflow endpoint for mainnet (MONERIUM_B2B_PRIVATE_RPC_URL). */ +export const DEFAULT_PRIVATE_RPC_URL = "https://rpc.flashbots.net"; + +/** + * Client notification confirmation depth in blocks — registry P9 + * (docs/prd/monerium-onramp-deferred-decisions.md). Not consumed by the keeper itself + * (execution finality is handled via receipt + reorg-safe deposit identity); reserved + * for the notification job (plan §3, "Notifications"). + */ +export const NOTIFY_CONFIRMATION_DEPTH = 32; + +// ------------------------------------------------------------------ ABI surface + +// Hand-written minimal ABIs (no codegen) mirroring +// contracts/monerium-forwarder/src/VortexForwarder.sol + VortexForwarderFactory.sol. + +export const eureTransferEvent = parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 value)"); + +export const erc20Abi = [ + { + inputs: [{ name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +export const forwarderAbi = [ + { inputs: [], name: "poke", outputs: [], stateMutability: "nonpayable", type: "function" }, + { inputs: [], name: "swapAndForward", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [{ name: "paused", type: "bool" }], + name: "setGuardianPaused", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, + { inputs: [], name: "strandedSince", outputs: [{ name: "", type: "uint64" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "guardianPaused", outputs: [{ name: "", type: "bool" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "EURE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "FACTORY", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { + anonymous: false, + inputs: [{ indexed: false, name: "strandedSince", type: "uint64" }], + name: "Poked", + type: "event" + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "eureIn", type: "uint256" }, + { indexed: false, name: "usdcOut", type: "uint256" }, + { indexed: false, name: "fee", type: "uint256" }, + { indexed: false, name: "forwarded", type: "uint256" } + ], + name: "SwapExecuted", + type: "event" + }, + { + anonymous: false, + inputs: [{ indexed: false, name: "paused", type: "bool" }], + name: "GuardianPausedSet", + type: "event" + } +] as const; + +export const factoryAbi = [ + { inputs: [], name: "minSwapAmount", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "perSwapCap", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "MIN_SWAP_FLOOR", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" } +] as const; + +// ------------------------------------------------------------------ clients + +export type KeeperWalletClient = WalletClient; + +let publicClientCache: PublicClient | null = null; +let keeperClientCache: KeeperWalletClient | null = null; +let guardianClientCache: KeeperWalletClient | null = null; +let privateRpcWarned = false; + +export function isKeeperChainConfigured(): boolean { + return Boolean(config.moneriumB2b.rpcUrl && config.moneriumB2b.keeperPrivateKey); +} + +/** Read/receipt client on the public RPC (MONERIUM_B2B_RPC_URL). */ +export function getPublicClient(): PublicClient { + if (!publicClientCache) { + const { rpcUrl } = config.moneriumB2b; + if (!rpcUrl) { + throw new Error("MONERIUM_B2B_RPC_URL is not configured"); + } + publicClientCache = createPublicClient({ transport: http(rpcUrl) }); + } + return publicClientCache; +} + +/** + * Submission endpoint for keeper/guardian transactions. Prefers the dedicated private + * orderflow RPC; falls back to the public RPC with a warning when unset (fine on + * sandbox/testnet where DEFAULT_PRIVATE_RPC_URL, a mainnet endpoint, does not apply). + */ +function submissionRpcUrl(): string { + const { privateRpcUrl, rpcUrl } = config.moneriumB2b; + if (privateRpcUrl) { + return privateRpcUrl; + } + if (!rpcUrl) { + throw new Error("MONERIUM_B2B_RPC_URL is not configured"); + } + if (!privateRpcWarned) { + privateRpcWarned = true; + logger.warn( + "monerium-b2b: MONERIUM_B2B_PRIVATE_RPC_URL is not set — keeper transactions will be submitted via the public RPC " + + `without private orderflow protection. Set it (e.g. ${DEFAULT_PRIVATE_RPC_URL}) for mainnet.` + ); + } + return rpcUrl; +} + +/** Keeper wallet client (MONERIUM_B2B_KEEPER_PRIVATE_KEY) on the submission transport. */ +export function getKeeperWalletClient(): KeeperWalletClient { + if (!keeperClientCache) { + const key = config.moneriumB2b.keeperPrivateKey; + if (!key) { + // Never include key material in errors or logs. + throw new Error("MONERIUM_B2B_KEEPER_PRIVATE_KEY is not configured"); + } + keeperClientCache = createWalletClient({ + account: privateKeyToAccount(key as Hex), + transport: http(submissionRpcUrl()) + }); + } + return keeperClientCache; +} + +/** + * Guardian wallet client (MONERIUM_B2B_GUARDIAN_PRIVATE_KEY) for the dormancy-gate + * pause. Returns null when the key is unset — the dormancy gate then runs in log-only + * mode. The guardian key is deliberately separate from the keeper key: it can only + * pause (protective-only invariant, plan §2.2), never move funds. + */ +export function getGuardianWalletClient(): KeeperWalletClient | null { + if (!config.moneriumB2b.guardianPrivateKey) { + return null; + } + if (!guardianClientCache) { + guardianClientCache = createWalletClient({ + account: privateKeyToAccount(config.moneriumB2b.guardianPrivateKey as Hex), + transport: http(submissionRpcUrl()) + }); + } + return guardianClientCache; +} + +// ------------------------------------------------------------------ cached chain lookups + +let chainIdCache: number | null = null; + +export async function getChainId(): Promise { + if (chainIdCache === null) { + chainIdCache = await getPublicClient().getChainId(); + } + return chainIdCache; +} + +interface ForwarderImmutables { + eure: Address; + factory: Address; +} + +// EURE/FACTORY are implementation-level immutables shared by every clone, so one +// lookup per forwarder address is enough for the process lifetime. +const forwarderImmutablesCache = new Map(); + +export async function getForwarderImmutables(forwarderAddress: Address): Promise { + const key = forwarderAddress.toLowerCase(); + const cached = forwarderImmutablesCache.get(key); + if (cached) { + return cached; + } + const client = getPublicClient(); + const [eure, factory] = await Promise.all([ + client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName: "EURE" }), + client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName: "FACTORY" }) + ]); + const immutables = { eure, factory }; + forwarderImmutablesCache.set(key, immutables); + return immutables; +} diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts new file mode 100644 index 000000000..55c3b211f --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test"; +import { AllocatableDeposit, allocateUsdcProRata, selectDepositsForExecution } from "./conversion-executor"; + +// R04 attribution (docs/prd/monerium-b2b-implementation-plan.md §3): pro-rata by +// amount_raw against eureInRaw, floor division, remainder to the largest deposit. +// No chain or database involved — pure math. + +const EUR = 10n ** 18n; +const USDC = 10n ** 6n; + +function deposit(id: string, amountRaw: bigint): AllocatableDeposit { + return { amountRaw, id }; +} + +describe("selectDepositsForExecution", () => { + it("selects all deposits when they fit within eureInRaw", () => { + const deposits = [deposit("a", 100n * EUR), deposit("b", 50n * EUR)]; + expect(selectDepositsForExecution(deposits, 150n * EUR)).toEqual(deposits); + }); + + it("stops before a deposit that would exceed the per-swap cap cut", () => { + const deposits = [deposit("a", 50n * EUR), deposit("b", 30n * EUR)]; + // eureIn = 60: the 30-EUR deposit would push cumulative to 80 — it waits for the + // next execution instead of being over-attributed to this one. + expect(selectDepositsForExecution(deposits, 60n * EUR)).toEqual([deposits[0]]); + }); + + it("selects nothing when even the oldest deposit exceeds eureInRaw", () => { + expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([]); + }); + + it("handles an exact fit and an empty list", () => { + const deposits = [deposit("a", 25n * EUR), deposit("b", 75n * EUR)]; + expect(selectDepositsForExecution(deposits, 100n * EUR)).toEqual(deposits); + expect(selectDepositsForExecution([], 100n * EUR)).toEqual([]); + }); +}); + +describe("allocateUsdcProRata", () => { + it("gives a single deposit covering the full eureIn the entire net USDC", () => { + const shares = allocateUsdcProRata([deposit("a", 100n * EUR)], 100n * EUR, 108n * USDC); + expect(shares.get("a")).toBe(108n * USDC); + }); + + it("splits proportionally when amounts divide evenly", () => { + const shares = allocateUsdcProRata([deposit("a", 75n * EUR), deposit("b", 25n * EUR)], 100n * EUR, 100n * USDC); + expect(shares.get("a")).toBe(75n * USDC); + expect(shares.get("b")).toBe(25n * USDC); + }); + + it("floors each share and gives the division remainder to the largest deposit", () => { + // 100 USDC over three equal thirds: floor gives 33.333333 each, 1 raw unit of dust + // remains and goes to the largest (tie -> earliest). + const shares = allocateUsdcProRata( + [deposit("a", 1n * EUR), deposit("b", 1n * EUR), deposit("c", 1n * EUR)], + 3n * EUR, + 100n * USDC + ); + expect(shares.get("a")).toBe(33333334n); + expect(shares.get("b")).toBe(33333333n); + expect(shares.get("c")).toBe(33333333n); + expect([...shares.values()].reduce((sum, share) => sum + share, 0n)).toBe(100n * USDC); + }); + + it("gives the remainder to the largest deposit, not the first", () => { + const shares = allocateUsdcProRata([deposit("small", 1n * EUR), deposit("big", 2n * EUR)], 3n * EUR, 100n * USDC); + expect(shares.get("small")).toBe(33333333n); + expect(shares.get("big")).toBe(66666667n); + }); + + it("handles a dust deposit whose floor share is zero", () => { + // 1 raw-unit deposit against 100 EUR in: floor share is 0; the sum invariant holds + // because the remainder lands on the large deposit. + const shares = allocateUsdcProRata([deposit("dust", 1n), deposit("big", 100n * EUR - 1n)], 100n * EUR, 100n * USDC); + expect(shares.get("dust")).toBe(0n); + expect(shares.get("big")).toBe(100n * USDC); + }); + + it("conserves the total exactly whenever the selection covers eureInRaw", () => { + const deposits = [deposit("a", 7n * EUR), deposit("b", 13n * EUR), deposit("c", 17n * EUR)]; + const usdcNet = 39_876_543n; + const shares = allocateUsdcProRata(deposits, 37n * EUR, usdcNet); + expect([...shares.values()].reduce((sum, share) => sum + share, 0n)).toBe(usdcNet); + }); + + it("returns an empty allocation for an empty selection or non-positive eureIn", () => { + expect(allocateUsdcProRata([], 100n * EUR, 100n * USDC).size).toBe(0); + expect(allocateUsdcProRata([deposit("a", 1n * EUR)], 0n, 100n * USDC).size).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts new file mode 100644 index 000000000..90a9e2a68 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -0,0 +1,392 @@ +import { Op, Transaction } from "sequelize"; +import { Address, parseEventLogs, TransactionReceipt } from "viem"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { erc20Abi, factoryAbi, forwarderAbi, getForwarderImmutables, getKeeperWalletClient, getPublicClient } from "./chain"; +import { withForwarderLock } from "./deposit-processor"; + +/** + * Per-account conversion executor (plan §3, "Keeper" + "Attribution (R04)"): + * balance >= minSwapAmount -> poke() (stranding marker, R03) + swapAndForward() via the + * private submission transport, with an execution record created and committed BEFORE + * anything is sent, then snapshot-based deposit attribution on confirmation. + * + * Serialization: every database mutation runs inside the per-forwarder advisory lock + * (withForwarderLock). The chain send/wait itself deliberately happens OUTSIDE a lock — + * holding a transaction open across RPC waits would pin a connection for minutes, and + * crash-safety requires the pending execution row to be durably COMMITTED before the + * transaction is broadcast (a row inside an open transaction would roll back on crash). + * Double-send is instead prevented by the "any pending execution -> skip" check, which + * runs under the lock. + */ + +/** Retry backoff for failed executions: base * 2^attempts, capped. Kept deliberately minimal. */ +const RETRY_BASE_MS = 60_000; +const RETRY_MAX_MS = 60 * 60_000; + +/** A pending execution with a tx hash but no receipt after this long is declared failed. */ +const PENDING_TX_STALE_MS = 15 * 60_000; + +/** How long one cycle waits for the swap receipt before deferring to the next cycle. */ +const RECEIPT_TIMEOUT_MS = 3 * 60_000; + +// ------------------------------------------------------------------ R04 allocation math + +export interface AllocatableDeposit { + id: string; + amountRaw: bigint; +} + +/** + * Snapshot selection honoring the per-swap cap: deposits are taken oldest-mint-first + * until the next one would push the cumulative amount past eureInRaw (a cap-cut deposit + * stays unallocated and joins the next execution). Callers pass only unallocated minted + * deposits with mint block <= execution block (R04). + */ +export function selectDepositsForExecution(deposits: AllocatableDeposit[], eureInRaw: bigint): AllocatableDeposit[] { + const selected: AllocatableDeposit[] = []; + let cumulative = 0n; + for (const deposit of deposits) { + if (cumulative + deposit.amountRaw > eureInRaw) { + break; + } + cumulative += deposit.amountRaw; + selected.push(deposit); + } + return selected; +} + +/** + * R04 pro-rata attribution of the execution's net USDC: each deposit gets + * floor(usdcNetRaw * amountRaw / eureInRaw); the remainder (floor dust, plus any value + * from inflows not represented in the selection) goes to the largest deposit (ties: the + * earliest). Sum of shares always equals usdcNetRaw for a non-empty selection. + */ +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 remainder = usdcNetRaw - allocated; + if (remainder > 0n) { + shares.set(largest.id, (shares.get(largest.id) as bigint) + remainder); + } + return shares; +} + +// ------------------------------------------------------------------ finalization + attribution + +function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 500); +} + +async function allocateDeposits(execution: MoneriumConversionExecution, transaction: Transaction): Promise { + if (execution.blockNumber === null) { + return; + } + // R04 snapshot: unallocated minted deposits with mint block <= execution block, + // oldest mint first. Unattributed inflow rows participate: their EURe was part of the + // swapped balance, and linking them marks the inflow as consumed by this execution. + const deposits = await MoneriumFiatDeposit.findAll({ + order: [ + ["block_number", "ASC"], + ["log_index", "ASC"] + ], + transaction, + where: { + accountId: execution.accountId, + allocatedExecutionId: null, + blockNumber: { [Op.lte]: execution.blockNumber }, + status: MoneriumFiatDepositStatus.Minted + } + }); + const eureInRaw = BigInt(execution.eureInRaw); + const selected = selectDepositsForExecution( + deposits.map(deposit => ({ amountRaw: BigInt(deposit.amountRaw), id: deposit.id })), + eureInRaw + ); + if (selected.length === 0) { + return; + } + const shares = allocateUsdcProRata(selected, eureInRaw, BigInt(execution.usdcNetRaw ?? "0")); + const selectedIds = selected.map(deposit => deposit.id); + await MoneriumFiatDeposit.update( + { allocatedExecutionId: execution.id }, + { transaction, where: { id: { [Op.in]: selectedIds } } } + ); + logger.info( + `monerium-b2b: execution ${execution.id} allocated ${selectedIds.length} deposit(s): ` + + [...shares.entries()].map(([id, share]) => `${id}=${share.toString()}`).join(", ") + ); +} + +/** Applies a mined receipt to a pending execution: confirmed + event amounts + R04 allocation, or failed on revert. */ +async function finalizeExecution( + execution: MoneriumConversionExecution, + receipt: TransactionReceipt, + forwarderAddress: string, + transaction: Transaction +): Promise { + if (receipt.status !== "success") { + await execution.update( + { + blockNumber: Number(receipt.blockNumber), + error: "swapAndForward reverted", + status: MoneriumConversionExecutionStatus.Failed + }, + { transaction } + ); + return; + } + const swapEvents = parseEventLogs({ abi: forwarderAbi, eventName: "SwapExecuted", logs: receipt.logs }).filter( + log => log.address.toLowerCase() === forwarderAddress.toLowerCase() + ); + if (swapEvents.length === 0) { + // A successful swapAndForward always emits SwapExecuted; treat absence as failure. + await execution.update( + { + blockNumber: Number(receipt.blockNumber), + error: "receipt succeeded but no SwapExecuted event was emitted by the forwarder", + status: MoneriumConversionExecutionStatus.Failed + }, + { transaction } + ); + return; + } + const { eureIn, usdcOut, fee, forwarded } = swapEvents[0].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(), + feeRaw: fee.toString(), + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: receipt.transactionHash, + usdcGrossRaw: usdcOut.toString(), + usdcNetRaw: forwarded.toString() + }, + { transaction } + ); + await allocateDeposits(execution, transaction); +} + +// ------------------------------------------------------------------ pending resolution + backoff + +type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; reason: string }; + +/** + * Under the forwarder lock: resolve leftover pending executions (crash/timeout + * recovery), then decide whether a new execution may start (retry backoff). + */ +async function prepareExecutionSlot(account: MoneriumAccount, transaction: Transaction): Promise { + const pendings = await MoneriumConversionExecution.findAll({ + order: [["created_at", "ASC"]], + transaction, + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Pending } + }); + for (const pending of pendings) { + if (!pending.txHash) { + // Execution-before-send record with no hash: the process died between commit and + // broadcast, so nothing is in flight — safe to fail and retry. + await pending.update( + { error: "crashed before the transaction was sent", status: MoneriumConversionExecutionStatus.Failed }, + { transaction } + ); + continue; + } + const receipt = await getPublicClient() + .getTransactionReceipt({ hash: pending.txHash as Address }) + .catch(() => null); + if (receipt) { + await finalizeExecution(pending, receipt, account.forwarderAddress, transaction); + } else if (Date.now() - pending.updatedAt.getTime() > PENDING_TX_STALE_MS) { + await pending.update( + { error: "timed out waiting for a receipt", status: MoneriumConversionExecutionStatus.Failed }, + { transaction } + ); + } else { + return { kind: "skip", reason: `execution ${pending.id} still awaiting receipt ${pending.txHash}` }; + } + } + + // Backoff over consecutive failures since the last confirmed execution. + const lastConfirmed = await MoneriumConversionExecution.findOne({ + order: [["created_at", "DESC"]], + transaction, + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Confirmed } + }); + const failedSince: MoneriumConversionExecution[] = await MoneriumConversionExecution.findAll({ + order: [["created_at", "DESC"]], + transaction, + where: { + accountId: account.id, + status: MoneriumConversionExecutionStatus.Failed, + ...(lastConfirmed ? { createdAt: { [Op.gt]: lastConfirmed.createdAt } } : {}) + } + }); + if (failedSince.length > 0) { + const backoffMs = Math.min(RETRY_BASE_MS * 2 ** (failedSince.length - 1), RETRY_MAX_MS); + const nextAttemptAt = failedSince[0].updatedAt.getTime() + backoffMs; + if (Date.now() < nextAttemptAt) { + return { kind: "skip", reason: `retry backoff until ${new Date(nextAttemptAt).toISOString()}` }; + } + } + return { attempt: failedSince.length + 1, kind: "proceed" }; +} + +// ------------------------------------------------------------------ executor + +/** + * Runs one conversion cycle for an account. Safe to call for accounts with nothing to + * do (cheap chain reads, then returns). + */ +export async function runConversionExecutor(accountId: string): Promise { + const account = await MoneriumAccount.findByPk(accountId); + if (!account) { + return; + } + if (account.status === MoneriumAccountStatus.Suspended || account.status === MoneriumAccountStatus.Closed) { + return; + } + if (account.dormantSince) { + // Guardian-paused for dormancy — swapAndForward would revert Paused(). Unpausing is + // manual after partner re-confirmation (registry B5). + return; + } + + const client = getPublicClient(); + const forwarder = account.forwarderAddress as Address; + const { eure, factory } = await getForwarderImmutables(forwarder); + const [balance, strandedSince, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) + ]); + + // R03: arm the stranding marker whenever funds cross the immutable floor, even below + // the (guardian-tunable) minSwapAmount — the dead-man timers must start regardless of + // whether a swap is currently possible. + const pokeNeeded = strandedSince === 0n && balance >= minSwapFloor; + + if (balance < minSwapAmount) { + if (pokeNeeded) { + await sendPoke(forwarder); + } + return; + } + + const preparation = await withForwarderLock(account.forwarderAddress, transaction => + prepareExecutionSlot(account, transaction) + ); + if (preparation.kind === "skip") { + logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${preparation.reason}`); + return; + } + + // Execution-before-send record (plan §3): committed before any broadcast so a crash + // leaves an auditable pending row, never an untracked on-chain swap. + const execution = await withForwarderLock(account.forwarderAddress, transaction => + MoneriumConversionExecution.create( + { + accountId: account.id, + destination: account.destination, + eureInRaw: (balance > perSwapCap ? perSwapCap : balance).toString() + }, + { transaction } + ) + ); + + try { + const keeper = getKeeperWalletClient(); + // Explicit nonces: poke + swap are sent back-to-back through the private transport, + // which may not expose a coherent pending pool for nonce derivation. + let nonce = await client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }); + + if (pokeNeeded) { + await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); + await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke", + nonce: nonce++ + }); + } + + await client.simulateContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + functionName: "swapAndForward" + }); + const txHash = await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "swapAndForward", + nonce + }); + await execution.update({ txHash }); + + const receipt = await client.waitForTransactionReceipt({ hash: txHash, timeout: RECEIPT_TIMEOUT_MS }); + await withForwarderLock(account.forwarderAddress, transaction => + finalizeExecution(execution, receipt, account.forwarderAddress, transaction) + ); + } catch (error) { + if (execution.txHash) { + // The transaction is (or may be) in flight; leave the row pending — the next + // cycle resolves it via receipt lookup or declares it stale. + logger.warn(`monerium-b2b: execution ${execution.id} awaiting receipt after error: ${errorText(error)}`); + return; + } + await execution.update({ + error: `attempt ${preparation.attempt}: ${errorText(error)}`, + status: MoneriumConversionExecutionStatus.Failed + }); + logger.error(`monerium-b2b: conversion for account ${account.id} failed (attempt ${preparation.attempt}):`, error); + } +} + +/** Standalone stranding-marker poke for balances between the floor and minSwapAmount. */ +async function sendPoke(forwarder: Address): Promise { + try { + const client = getPublicClient(); + const keeper = getKeeperWalletClient(); + await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); + const hash = await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke" + }); + logger.info(`monerium-b2b: poked forwarder ${forwarder} (${hash})`); + } catch (error) { + // Best-effort: poke is also permissionless on-chain, so a missed poke only delays + // the stranding timers until the next cycle. + logger.warn(`monerium-b2b: poke for forwarder ${forwarder} failed: ${errorText(error)}`); + } +} diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts new file mode 100644 index 000000000..305f0b476 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { isForwardTransition, mapOrderStateToDepositStatus, parseOrderEvent } from "./deposit-processor"; + +const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; + +describe("forward-only deposit status transitions", () => { + it("allows pending to progress to minted, held, or returned", () => { + expect(isForwardTransition(Pending, Minted)).toBe(true); + expect(isForwardTransition(Pending, Held)).toBe(true); + expect(isForwardTransition(Pending, Returned)).toBe(true); + }); + + it("allows a hold to resolve to minted or returned but never back to pending", () => { + expect(isForwardTransition(Held, Minted)).toBe(true); + expect(isForwardTransition(Held, Returned)).toBe(true); + expect(isForwardTransition(Held, Pending)).toBe(false); + }); + + it("treats minted and returned as terminal", () => { + for (const to of [Pending, Held, Returned]) { + expect(isForwardTransition(Minted, to)).toBe(false); + } + for (const to of [Pending, Held, Minted]) { + expect(isForwardTransition(Returned, to)).toBe(false); + } + }); + + it("never allows a self-transition write", () => { + for (const status of [Pending, Held, Minted, Returned]) { + expect(isForwardTransition(status, status)).toBe(false); + } + }); +}); + +describe("mapOrderStateToDepositStatus", () => { + it("maps documented Monerium order states", () => { + expect(mapOrderStateToDepositStatus("placed")).toBe(Pending); + expect(mapOrderStateToDepositStatus("pending")).toBe(Pending); + expect(mapOrderStateToDepositStatus("processed")).toBe(Minted); + expect(mapOrderStateToDepositStatus("rejected")).toBe(Returned); + expect(mapOrderStateToDepositStatus("held")).toBe(Held); + }); + + it("normalizes case and whitespace, and returns null for unknown states", () => { + expect(mapOrderStateToDepositStatus(" Processed ")).toBe(Minted); + expect(mapOrderStateToDepositStatus("something-new")).toBeNull(); + expect(mapOrderStateToDepositStatus("")).toBeNull(); + }); +}); + +describe("parseOrderEvent", () => { + const validPayload = { + data: { + address: "0x1111111111111111111111111111111111111111", + amount: "100.5", + currency: "eur", + id: "order-1", + kind: "issue", + meta: { txHash: "0xabc" }, + state: "processed" + }, + timestamp: "2026-07-17T00:00:00Z", + type: "order.updated" + }; + + it("extracts the issue-order fields", () => { + expect(parseOrderEvent(validPayload)).toEqual({ + amount: "100.5", + currency: "eur", + forwarderAddress: "0x1111111111111111111111111111111111111111", + orderId: "order-1", + state: "processed", + txHash: "0xabc" + }); + }); + + it("ignores redeem orders, non-order events, and malformed payloads", () => { + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, kind: "redeem" } })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, type: "profile.updated" })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, id: undefined } })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, amount: 100.5 } })).toBeNull(); + expect(parseOrderEvent(null)).toBeNull(); + expect(parseOrderEvent("junk")).toBeNull(); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts new file mode 100644 index 000000000..db8a20f75 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -0,0 +1,180 @@ +import { Transaction } from "sequelize"; +import { parseUnits } from "viem"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; + +/** + * Asynchronous processor for the durable webhook inbox (plan §3): upserts + * MoneriumFiatDeposit rows by monerium_order_id with forward-only status transitions, + * serialized per forwarder address via a Postgres transaction-scoped advisory lock. + */ + +const EURE_DECIMALS = 18; + +/** + * Runs `fn` inside a transaction holding the per-forwarder advisory lock (plan §3): + * the lock is transaction-scoped, so concurrent processors (multiple instances, + * webhook-triggered + scheduled runs, mint watcher, conversion executor) apply writes + * for one account strictly one at a time. Shared serialization point for the whole + * monerium-b2b module. + */ +export async function withForwarderLock(forwarderAddress: string, fn: (transaction: Transaction) => Promise): Promise { + const forwarderKey = forwarderAddress.toLowerCase(); + return sequelize.transaction(async transaction => { + await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: `monerium-b2b:${forwarderKey}` }, + transaction + }); + return fn(transaction); + }); +} + +// Forward-only lattice (plan §3): pending → minted/held/returned; a compliance hold can +// still resolve to minted or returned; minted/returned are terminal. +const FORWARD_TRANSITIONS: Record = { + [MoneriumFiatDepositStatus.Pending]: [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Held, + MoneriumFiatDepositStatus.Returned + ], + [MoneriumFiatDepositStatus.Held]: [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Returned], + [MoneriumFiatDepositStatus.Minted]: [], + [MoneriumFiatDepositStatus.Returned]: [] +}; + +export function isForwardTransition(from: MoneriumFiatDepositStatus, to: MoneriumFiatDepositStatus): boolean { + return FORWARD_TRANSITIONS[from].includes(to); +} + +/** + * Maps a Monerium issue-order state to a deposit status, or null for states we do not + * (yet) recognize. TODO(sandbox): pin the exact upstream state vocabulary — "processed" + * and "rejected" are documented; the compliance-hold value is a sandbox-verification item. + */ +export function mapOrderStateToDepositStatus(state: string): MoneriumFiatDepositStatus | null { + switch (state.trim().toLowerCase()) { + case "placed": + case "pending": + return MoneriumFiatDepositStatus.Pending; + case "processed": + return MoneriumFiatDepositStatus.Minted; + case "held": + case "on_hold": + return MoneriumFiatDepositStatus.Held; + case "rejected": + case "returned": + return MoneriumFiatDepositStatus.Returned; + default: + return null; + } +} + +interface ParsedOrderEvent { + orderId: string; + forwarderAddress: string; + amount: string; + currency: string; + state: string; + txHash: string | null; +} + +/** + * Extracts the issue-order fields this processor acts on from a delivery payload + * (documented shape: { type, timestamp, data }). Returns null for deliveries that are + * not EURe issue orders — those are acked and marked processed without a deposit write. + */ +export function parseOrderEvent(payload: unknown): ParsedOrderEvent | null { + const envelope = payload as { data?: unknown; type?: unknown } | null; + if (!envelope || typeof envelope !== "object") return null; + if (typeof envelope.type === "string" && !envelope.type.startsWith("order")) return null; + const data = (envelope.data ?? envelope) as Record; + if (typeof data.kind === "string" && data.kind !== "issue") return null; + if (typeof data.id !== "string" || typeof data.address !== "string" || typeof data.amount !== "string") return null; + const state = typeof data.state === "string" ? data.state : ""; + const meta = (data.meta ?? {}) as Record; + return { + amount: data.amount, + currency: typeof data.currency === "string" ? data.currency : "eur", + forwarderAddress: data.address, + orderId: data.id, + state, + txHash: typeof meta.txHash === "string" ? meta.txHash : null + }; +} + +async function processInboxRow(row: MoneriumWebhookEvent): Promise { + const event = parseOrderEvent(row.payload); + if (!event) { + await row.update({ processedAt: new Date() }); + return; + } + + const forwarderKey = event.forwarderAddress.toLowerCase(); + await withForwarderLock(forwarderKey, async transaction => { + const account = await MoneriumAccount.findOne({ + transaction, + where: sequelize.where(sequelize.fn("lower", sequelize.col("forwarder_address")), forwarderKey) + }); + if (!account) { + logger.warn(`monerium-b2b: webhook order ${event.orderId} references unknown forwarder address, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + + const targetStatus = mapOrderStateToDepositStatus(event.state); + const existing = await MoneriumFiatDeposit.findOne({ transaction, where: { moneriumOrderId: event.orderId } }); + if (!existing) { + await MoneriumFiatDeposit.create( + { + accountId: account.id, + amountRaw: parseUnits(event.amount, EURE_DECIMALS).toString(), + currency: event.currency, + moneriumOrderId: event.orderId, + status: targetStatus ?? MoneriumFiatDepositStatus.Pending, + txHash: event.txHash + }, + { transaction } + ); + } else if (targetStatus && targetStatus !== existing.status) { + if (isForwardTransition(existing.status, targetStatus)) { + await existing.update( + { status: targetStatus, ...(event.txHash && !existing.txHash ? { txHash: event.txHash } : {}) }, + { + transaction + } + ); + } else { + logger.warn( + `monerium-b2b: ignoring backward status transition ${existing.status} -> ${targetStatus} for order ${event.orderId}` + ); + } + } + + await row.update({ processedAt: new Date() }, { transaction }); + }); +} + +/** + * Processes all unprocessed inbox rows oldest-first. A row that fails stays + * unprocessed and is retried on the next run; rows we recognize but choose to skip are + * marked processed so they cannot poison the loop. + */ +export async function processMoneriumWebhookInbox(): Promise { + const rows = await MoneriumWebhookEvent.findAll({ + order: [["created_at", "ASC"]], + where: { processedAt: null } + }); + let processed = 0; + for (const row of rows) { + try { + await processInboxRow(row); + processed += 1; + } catch (error) { + logger.error(`monerium-b2b: failed to process webhook inbox row ${row.eventId}:`, error); + } + } + return processed; +} diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.test.ts b/apps/api/src/api/services/monerium-b2b/dormancy.test.ts new file mode 100644 index 000000000..f5f6a07bf --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/dormancy.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { DORMANCY_WINDOW_MS, isDormancyCandidate } from "./dormancy"; + +// Dormancy selection (plan §3, R05; window = registry P5, 60 days). Pure predicate — +// runDormancyGate applies it per active account with its latest confirmed execution. + +const NOW = new Date("2026-07-17T12:00:00Z"); + +function daysAgo(days: number): Date { + return new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000); +} + +function account(overrides: Partial[0]> = {}) { + return { + createdAt: daysAgo(365), + dormantSince: null, + status: MoneriumAccountStatus.Active, + ...overrides + }; +} + +describe("isDormancyCandidate", () => { + it("uses a 60-day window (registry P5)", () => { + expect(DORMANCY_WINDOW_MS).toBe(60 * 24 * 60 * 60 * 1000); + }); + + it("flags an active account whose last confirmed conversion is older than the window", () => { + expect(isDormancyCandidate(account(), daysAgo(61), NOW)).toBe(true); + }); + + it("does not flag an account with a recent confirmed conversion", () => { + expect(isDormancyCandidate(account(), daysAgo(59), NOW)).toBe(false); + }); + + it("treats exactly-at-the-window as dormant (inclusive boundary)", () => { + expect(isDormancyCandidate(account(), daysAgo(60), NOW)).toBe(true); + }); + + it("anchors never-converted accounts on their creation date", () => { + expect(isDormancyCandidate(account({ createdAt: daysAgo(61) }), null, NOW)).toBe(true); + expect(isDormancyCandidate(account({ createdAt: daysAgo(10) }), null, NOW)).toBe(false); + }); + + it("never re-flags an account already marked dormant", () => { + expect(isDormancyCandidate(account({ dormantSince: daysAgo(5) }), daysAgo(90), NOW)).toBe(false); + }); + + it("only applies to active accounts (status itself is not changed by the gate)", () => { + for (const status of [ + MoneriumAccountStatus.Onboarding, + MoneriumAccountStatus.Suspended, + MoneriumAccountStatus.Closed + ]) { + expect(isDormancyCandidate(account({ status }), daysAgo(90), NOW)).toBe(false); + } + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.ts b/apps/api/src/api/services/monerium-b2b/dormancy.ts new file mode 100644 index 000000000..9d2a0ec26 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/dormancy.ts @@ -0,0 +1,92 @@ +import { Address } from "viem"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import { forwarderAbi, getGuardianWalletClient, getPublicClient } from "./chain"; + +/** + * Dormancy gate (plan §3, R05): accounts with no successful forward for the dormancy + * window get a protective per-clone guardian pause. The pause can never move funds or + * block the client's fallback paths (contract invariant, plan §2.2); account status + * stays `active` — the pause lives on-chain, `dormant_since` records the detection. + * + * Un-pause is MANUAL for now: guardian ops call setGuardianPaused(false) after the + * partner re-confirms the client relationship — re-confirmation mechanics are a + * partner-agreement item (deferred-decisions registry B5). + */ + +/** Dormancy pause window — registry P5 (docs/prd/monerium-onramp-deferred-decisions.md). */ +export const DORMANCY_WINDOW_MS = 60 * 24 * 60 * 60 * 1000; + +export interface DormancyAccountFields { + createdAt: Date; + dormantSince: Date | null; + status: MoneriumAccountStatus; +} + +/** + * An account is a dormancy candidate iff it is active, not already flagged, and its + * last confirmed conversion (or, for never-converted accounts, its creation) is at + * least the dormancy window in the past. + */ +export function isDormancyCandidate( + account: DormancyAccountFields, + lastConfirmedAt: Date | null, + now: Date = new Date() +): boolean { + if (account.status !== MoneriumAccountStatus.Active || account.dormantSince !== null) { + return false; + } + const anchor = lastConfirmedAt ?? account.createdAt; + return now.getTime() - anchor.getTime() >= DORMANCY_WINDOW_MS; +} + +async function pauseDormantAccount(account: MoneriumAccount, now: Date): Promise { + const guardian = getGuardianWalletClient(); + if (!guardian) { + // Log-only mode (MONERIUM_B2B_GUARDIAN_PRIVATE_KEY unset): record the detection so + // it is not re-alerted every cycle, but state clearly that no on-chain pause exists. + logger.warn( + `monerium-b2b: account ${account.id} (forwarder ${account.forwarderAddress}) is dormant; ` + + "guardian key not configured — NOT pausing on-chain (log-only mode)" + ); + await account.update({ dormantSince: now }); + return; + } + + const client = getPublicClient(); + const forwarder = account.forwarderAddress as Address; + const { request } = await client.simulateContract({ + abi: forwarderAbi, + account: guardian.account, + address: forwarder, + args: [true], + functionName: "setGuardianPaused" + }); + const hash = await guardian.writeContract({ ...request, chain: null }); + await client.waitForTransactionReceipt({ hash }); + await account.update({ dormantSince: now }); + logger.info(`monerium-b2b: dormancy pause set for account ${account.id} (forwarder ${forwarder}, tx ${hash})`); +} + +/** Runs one dormancy-gate pass over all active, not-yet-flagged accounts. */ +export async function runDormancyGate(now: Date = new Date()): Promise { + const accounts = await MoneriumAccount.findAll({ + where: { dormantSince: null, status: MoneriumAccountStatus.Active } + }); + for (const account of accounts) { + try { + const lastConfirmed = await MoneriumConversionExecution.findOne({ + order: [["created_at", "DESC"]], + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Confirmed } + }); + if (isDormancyCandidate(account, lastConfirmed?.createdAt ?? null, now)) { + await pauseDormantAccount(account, now); + } + } catch (error) { + logger.error(`monerium-b2b: dormancy gate failed for account ${account.id}:`, error); + } + } +} diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts new file mode 100644 index 000000000..ca14d9764 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { + MatchableDeposit, + matchMintLogToDeposit, + syntheticUnattributedOrderId, + UNATTRIBUTED_ORDER_PREFIX +} from "./mint-watcher"; + +// Mint-log -> deposit matching (plan §3, "mint detection"). Pure decision logic; the +// database write path shares the advisory-locked transaction with the webhook processor. + +const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; + +const TX_A = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +function candidate(overrides: Partial & { id: string }): MatchableDeposit { + return { + amountRaw: (100n * 10n ** 18n).toString(), + logIndex: null, + status: Pending, + txHash: null, + ...overrides + } as MatchableDeposit; +} + +describe("matchMintLogToDeposit", () => { + it("matches by tx hash when the webhook already recorded the mint hash (case-insensitive)", () => { + const deposits = [ + candidate({ id: "other" }), + candidate({ id: "hash-match", status: Minted, txHash: TX_A.toLowerCase() }) + ]; + const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: 5n }, deposits); + expect(match?.id).toBe("hash-match"); + }); + + it("matches the oldest pending deposit with the exact mint amount", () => { + const amount = 250n * 10n ** 18n; + const deposits = [ + candidate({ amountRaw: (100n * 10n ** 18n).toString(), id: "wrong-amount" }), + candidate({ amountRaw: amount.toString(), id: "older" }), + candidate({ amountRaw: amount.toString(), id: "newer" }) + ]; + const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits); + expect(match?.id).toBe("older"); + }); + + it("returns null when nothing matches (unattributed fallback)", () => { + const deposits = [candidate({ amountRaw: "100", id: "a" })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: 999n }, deposits)).toBeNull(); + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: 999n }, [])).toBeNull(); + }); + + it("never matches deposits that already carry mint fields", () => { + const amount = 100n * 10n ** 18n; + const deposits = [candidate({ amountRaw: amount.toString(), id: "already-recorded", logIndex: 3 })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); + + it("never matches held or returned orders (they have not minted)", () => { + const amount = 100n * 10n ** 18n; + const deposits = [ + candidate({ amountRaw: amount.toString(), id: "held", status: Held }), + candidate({ amountRaw: amount.toString(), id: "returned", status: Returned }) + ]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); + + it("does not amount-match a minted deposit without a hash (hash is required once minted)", () => { + // A webhook-minted order without meta.txHash cannot be safely claimed by amount + // alone once it is already minted — only pending orders amount-match. + const amount = 100n * 10n ** 18n; + const deposits = [candidate({ amountRaw: amount.toString(), id: "minted-no-hash", status: Minted })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); +}); + +describe("syntheticUnattributedOrderId", () => { + it("is deterministic, flagged, and fits the 64-char order-id column", () => { + const id = syntheticUnattributedOrderId(1, TX_A, 7); + expect(id).toBe(syntheticUnattributedOrderId(1, TX_A.toLowerCase(), 7)); + expect(id.startsWith(UNATTRIBUTED_ORDER_PREFIX)).toBe(true); + expect(id.length).toBeLessThanOrEqual(64); + }); + + it("differs per chain, transaction, and log index", () => { + const base = syntheticUnattributedOrderId(1, TX_A, 7); + expect(syntheticUnattributedOrderId(2, TX_A, 7)).not.toBe(base); + expect(syntheticUnattributedOrderId(1, TX_A, 8)).not.toBe(base); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts new file mode 100644 index 000000000..8f24aa26d --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts @@ -0,0 +1,213 @@ +import crypto from "crypto"; +import { Op } from "sequelize"; +import { Address } from "viem"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { eureTransferEvent, getChainId, getForwarderImmutables, getPublicClient } from "./chain"; +import { withForwarderLock } from "./deposit-processor"; + +/** + * Poll-based EURe Transfer watcher (plan §3, "Keeper: mint detection"): scans a + * persisted-cursor block range for Transfers TO known forwarder addresses (from any + * sender), matches each mint to its pending Monerium order, and flags non-Monerium + * inflows as unattributed (R09: flagged, not treated as a customer deposit claim). + */ + +/** Upper bound on blocks scanned per cycle, so getLogs stays bounded after downtime. */ +const MAX_BLOCK_RANGE = 2000n; + +/** Order-id prefix marking a deposit row created from a mint log with no matching Monerium order. */ +export const UNATTRIBUTED_ORDER_PREFIX = "unattr:"; + +/** + * Deterministic synthetic order id for an unattributed mint (monerium_order_id is NOT + * NULL UNIQUE, max 64 chars — a raw tx hash would not fit alongside a prefix). + */ +export function syntheticUnattributedOrderId(chainId: number, txHash: string, logIndex: number): string { + const digest = crypto.createHash("sha256").update(`${chainId}:${txHash.toLowerCase()}:${logIndex}`).digest("hex"); + return `${UNATTRIBUTED_ORDER_PREFIX}${digest.slice(0, 56)}`; +} + +export interface MintLogFields { + txHash: string; + valueRaw: bigint; +} + +export type MatchableDeposit = Pick; + +/** + * Picks the deposit row a mint log belongs to, among the account's deposits that are + * still missing their mint fields (logIndex null). Precedence: + * 1. tx-hash match — the webhook may have already recorded the mint hash (order meta); + * 2. amount match on a pending order — oldest first (caller passes createdAt order). + * Held/returned orders never minted, so they are not candidates. Returns null when + * nothing matches (unattributed inflow). + */ +export function matchMintLogToDeposit(log: MintLogFields, candidates: MatchableDeposit[]): MatchableDeposit | null { + const open = candidates.filter( + deposit => + deposit.logIndex === null && + (deposit.status === MoneriumFiatDepositStatus.Pending || deposit.status === MoneriumFiatDepositStatus.Minted) + ); + const byHash = open.find(deposit => deposit.txHash !== null && deposit.txHash.toLowerCase() === log.txHash.toLowerCase()); + if (byHash) { + return byHash; + } + return ( + open.find( + deposit => + deposit.txHash === null && + deposit.status === MoneriumFiatDepositStatus.Pending && + BigInt(deposit.amountRaw) === log.valueRaw + ) ?? null + ); +} + +interface ObservedMint { + blockHash: string; + blockNumber: number; + logIndex: number; + to: Address; + txHash: string; + valueRaw: bigint; +} + +async function recordMint( + mint: ObservedMint, + chainId: number, + accountsByForwarder: Map +): Promise { + const account = accountsByForwarder.get(mint.to.toLowerCase()); + if (!account) { + // getLogs was filtered to known forwarders, so this only happens on a race with + // account archival; nothing to record against. + return null; + } + + return withForwarderLock(account.forwarderAddress, async transaction => { + // Idempotency: the (chain_id, tx_hash, log_index) partial unique index is the + // on-chain identity; a re-scan after a crash must not double-record. + const alreadyRecorded = await MoneriumFiatDeposit.findOne({ + transaction, + where: { chainId, logIndex: mint.logIndex, txHash: mint.txHash } + }); + if (alreadyRecorded) { + return null; + } + + const candidates = await MoneriumFiatDeposit.findAll({ + order: [["created_at", "ASC"]], + transaction, + where: { accountId: account.id, logIndex: null } + }); + const match = matchMintLogToDeposit({ txHash: mint.txHash, valueRaw: mint.valueRaw }, candidates); + + if (match) { + const deposit = candidates.find(row => row.id === match.id) as MoneriumFiatDeposit; + await deposit.update( + { + blockHash: mint.blockHash, + blockNumber: mint.blockNumber, + chainId, + logIndex: mint.logIndex, + // Forward-only: pending -> minted; a webhook-minted row just gains chain fields. + ...(deposit.status === MoneriumFiatDepositStatus.Pending ? { status: MoneriumFiatDepositStatus.Minted } : {}), + txHash: mint.txHash + }, + { transaction } + ); + } else { + // R09-adjacent: EURe arrived without a matching Monerium order (direct transfer, + // or the order webhook has not landed yet). Record it flagged as unattributed so + // the balance stays accounted for; it is never presented as a customer deposit. + logger.warn( + `monerium-b2b: unattributed EURe mint ${mint.txHash}#${mint.logIndex} of ${mint.valueRaw.toString()} raw to forwarder ${account.forwarderAddress}` + ); + await MoneriumFiatDeposit.create( + { + accountId: account.id, + amountRaw: mint.valueRaw.toString(), + blockHash: mint.blockHash, + blockNumber: mint.blockNumber, + chainId, + currency: "eur", + logIndex: mint.logIndex, + moneriumOrderId: syntheticUnattributedOrderId(chainId, mint.txHash, mint.logIndex), + status: MoneriumFiatDepositStatus.Minted, + txHash: mint.txHash + }, + { transaction } + ); + } + return account.id; + }); +} + +/** + * Runs one watcher cycle. Returns the ids of accounts that received new mints, so the + * worker can enqueue conversion for them immediately. + */ +export async function runMintWatcher(): Promise { + const accounts = await MoneriumAccount.findAll({ + where: { status: { [Op.ne]: MoneriumAccountStatus.Closed } } + }); + if (accounts.length === 0) { + return []; + } + const accountsByForwarder = new Map(accounts.map(account => [account.forwarderAddress.toLowerCase(), account])); + + const client = getPublicClient(); + const chainId = await getChainId(); + const { eure } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const latest = await client.getBlockNumber(); + + const cursorName = `eure-mints:${chainId}`; + const cursor = await MoneriumChainCursor.findByPk(cursorName); + if (!cursor) { + // Bootstrap: start watching from the current head. Historic mints are covered by + // webhook-recorded orders; back-filling their chain fields is a manual operation. + await MoneriumChainCursor.create({ lastBlock: latest.toString(), name: cursorName }); + return []; + } + + const fromBlock = BigInt(cursor.lastBlock) + 1n; + if (fromBlock > latest) { + return []; + } + const toBlock = latest - fromBlock + 1n > MAX_BLOCK_RANGE ? fromBlock + MAX_BLOCK_RANGE - 1n : latest; + + const logs = await client.getLogs({ + address: eure, + args: { to: accounts.map(account => account.forwarderAddress as Address) }, + event: eureTransferEvent, + fromBlock, + toBlock + }); + + const touchedAccounts = new Set(); + for (const log of logs) { + if (log.blockHash === null || log.blockNumber === null || log.transactionHash === null || log.logIndex === null) { + continue; // pending log — will be picked up once mined (cursor only advances over mined ranges) + } + const accountId = await recordMint( + { + blockHash: log.blockHash, + blockNumber: Number(log.blockNumber), + logIndex: log.logIndex, + to: log.args.to as Address, + txHash: log.transactionHash, + valueRaw: log.args.value as bigint + }, + chainId, + accountsByForwarder + ); + if (accountId) { + touchedAccounts.add(accountId); + } + } + + await cursor.update({ lastBlock: toBlock.toString() }); + return [...touchedAccounts]; +} diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts new file mode 100644 index 000000000..79b108497 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import { + classifyStranding, + computeQuoteImpactBps, + detectConfigDrift, + diffAssociation, + eip1167RuntimeCode, + normalizeIban, + STRANDED_WARN_MS +} from "./monitoring"; + +// Pure monitoring logic (implementation plan D3): quote-impact math against the T6 +// liquidity baseline, stranding severity, association-diff detection (S1 detective +// control) and config-drift classification (R07). No chain or API involved. + +const EUR = 10n ** 18n; +const USDC = 10n ** 6n; + +describe("computeQuoteImpactBps", () => { + // T6 baseline (registry, mainnet block 25553101): Chainlink 1.14410, QuoterV2 + // 10k EURe -> 1.14278 USDC/EURe. Impact vs oracle: (11441 - 11427.8) / 11441 = 11.5 bps. + const CHAINLINK_EUR_USD = 114410000n; // 8 decimals + + it("matches the T6 baseline impact at 10k EURe", () => { + const amountIn = 10_000n * EUR; + const quoted = 11_427_800_000n; // 10_000 * 1.14278 in USDC 6dp + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBe(11); + }); + + it("returns 0 for a quote exactly at the oracle rate", () => { + const amountIn = 1_000n * EUR; + const quoted = 1_144_100_000n; // 1_000 * 1.14410 + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBe(0); + }); + + it("is negative when the quote beats the oracle", () => { + const amountIn = 1_000n * EUR; + expect(computeQuoteImpactBps(amountIn, 1_150n * USDC, CHAINLINK_EUR_USD, 8)).toBeLessThan(0); + }); + + it("flags a pause-threshold breach above SLIPPAGE_BPS", () => { + const amountIn = 25n * EUR; // minSwapAmount placeholder (registry P6) + const expectedOut = (amountIn * CHAINLINK_EUR_USD) / 10n ** 20n; + const quoted = (expectedOut * 9_850n) / 10_000n; // 150 bps impact + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBeGreaterThan(100); // > P1 SLIPPAGE_BPS + }); + + it("handles a zero-ish expected output without dividing by zero", () => { + expect(computeQuoteImpactBps(0n, 0n, CHAINLINK_EUR_USD, 8)).toBe(0); + }); +}); + +describe("classifyStranding", () => { + const TRIGGER_DELAY = 86_400n; // 24h, registry P4 placeholder + const now = 1_800_000_000_000; // fixed epoch ms + + const armedAt = (msAgo: number): bigint => BigInt(Math.floor((now - msAgo) / 1000)); + + it("is ok when the marker is not armed", () => { + expect(classifyStranding(0n, TRIGGER_DELAY, now)).toBe("ok"); + }); + + it("is ok within the warn window", () => { + expect(classifyStranding(armedAt(60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("ok"); + }); + + it("warns after 12h", () => { + expect(classifyStranding(armedAt(STRANDED_WARN_MS + 60_000), TRIGGER_DELAY, now)).toBe("warn"); + }); + + it("errors past TRIGGER_DELAY", () => { + expect(classifyStranding(armedAt(25 * 60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("error"); + }); +}); + +describe("diffAssociation", () => { + const FORWARDER = "0xD7444AB7270A142227Fe659D63873ABdc8AF9b72"; + const IBAN = "EE08 7224 5745 6244 9516"; + const db = { forwarderAddress: FORWARDER, iban: IBAN }; + + it("reports no changes when the live state matches (case- and space-insensitively)", () => { + const live = { + ibans: [{ address: FORWARDER.toLowerCase(), iban: "ee08722457456244 9516" }], + profileAddresses: [FORWARDER.toLowerCase()] + }; + expect(diffAssociation(db, live)).toEqual([]); + }); + + it("detects the forwarder being unlinked", () => { + const changes = diffAssociation(db, { ibans: [{ address: FORWARDER, iban: IBAN }], profileAddresses: [] }); + expect(changes).toContain(`forwarder ${FORWARDER} is no longer linked to the profile`); + }); + + it("detects a new address linked to the profile", () => { + const intruder = "0x9999999999999999999999999999999999999999"; + const changes = diffAssociation(db, { + ibans: [{ address: FORWARDER, iban: IBAN }], + profileAddresses: [FORWARDER, intruder] + }); + expect(changes).toEqual([`unexpected address linked to the profile: ${intruder}`]); + }); + + it("detects the IBAN moving to another address (PATCH /ibans scenario)", () => { + const elsewhere = "0x8888888888888888888888888888888888888888"; + const changes = diffAssociation(db, { + ibans: [{ address: elsewhere, iban: IBAN }], + profileAddresses: [FORWARDER] + }); + expect(changes).toEqual([`IBAN ${IBAN} moved to address ${elsewhere}`]); + }); + + it("detects the IBAN disappearing", () => { + const changes = diffAssociation(db, { ibans: [], profileAddresses: [FORWARDER] }); + expect(changes).toEqual([`IBAN ${IBAN} no longer exists at Monerium`]); + }); + + it("detects an unrecorded IBAN on the forwarder", () => { + const other = "DE89370400440532013000"; + const changes = diffAssociation( + { forwarderAddress: FORWARDER, iban: null }, + { ibans: [{ address: FORWARDER, iban: other }], profileAddresses: [FORWARDER] } + ); + expect(changes).toEqual([`unrecorded IBAN issued for the forwarder: ${other}`]); + }); +}); + +describe("normalizeIban", () => { + it("strips whitespace and uppercases", () => { + expect(normalizeIban(" ee08 7224 5745\t6244 9516 ")).toBe("EE087224574562449516"); + }); +}); + +describe("detectConfigDrift", () => { + const base = { + destination: "0x1111111111111111111111111111111111111111", + fallbackAddress: "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + feeBps: 0 + }; + + it("reports nothing when the chain matches the db (case-insensitively)", () => { + const onchain = { ...base, destination: base.destination.toLowerCase() }; + expect(detectConfigDrift(base, onchain)).toEqual({ errors: [], ownerAuthorizedUpdates: {} }); + }); + + it("classifies destination/fallback changes as owner-authorized updates (R07)", () => { + const onchain = { + ...base, + destination: "0x4444444444444444444444444444444444444444", + fallbackAddress: "0x5555555555555555555555555555555555555555" + }; + const drift = detectConfigDrift(base, onchain); + expect(drift.errors).toEqual([]); + expect(drift.ownerAuthorizedUpdates).toEqual({ + destination: onchain.destination, + fallbackAddress: onchain.fallbackAddress + }); + }); + + it("classifies a feeBps change as an error, never a reconciliation", () => { + const drift = detectConfigDrift(base, { ...base, feeBps: 50 }); + expect(drift.errors).toEqual(["immutable feeBps mismatch: db=0 chain=50"]); + expect(drift.ownerAuthorizedUpdates).toEqual({}); + }); +}); + +describe("eip1167RuntimeCode", () => { + it("produces the canonical minimal-proxy runtime code for an implementation", () => { + expect(eip1167RuntimeCode("0x7e1c653CaAFCa44258d8680B09F42a33475504a9")).toBe( + "0x363d3d373d3d3d363d737e1c653caafca44258d8680b09f42a33475504a95af43d82803e903d91602b57fd5bf3" + ); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts new file mode 100644 index 000000000..b79ea2717 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -0,0 +1,457 @@ +import { Op } from "sequelize"; +import { Address, encodePacked, Hex, parseAbi } from "viem"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { erc20Abi, factoryAbi, forwarderAbi, getChainId, getForwarderImmutables, getPublicClient } from "./chain"; +import { getProfileAddresses, listIbans } from "./whitelabel-client"; + +/** + * Monitoring pass for the Monerium B2B onramp (implementation plan D3 / phase 3), run + * from the keeper worker. Four read-only monitors, alerting via the standard logger: + * + * 1. Executable-depth check (main PRD §7.4, T6 follow-up): QuoterV2 static quote on the + * pinned EURe->EURC->USDC path at perSwapCap and minSwapAmount sizes vs the + * Chainlink EUR/USD rate. Impact above SLIPPAGE_BPS at minSwapAmount size is the + * PAUSE THRESHOLD (error-level -> engage guardian pause per the incident runbook); + * at perSwapCap size it is an early warning. Mainnet-only (QuoterV2 pin). + * 2. Stranded-balance monitor: forwarders whose on-chain stranding marker (R03) has + * been armed for more than STRANDED_WARN_MS warn; past TRIGGER_DELAY (the + * permissionless-trigger delay, registry P4) they error — the keeper should have + * converted long before either. + * 3. Association monitor (S1 detective control, trust model in the b2b-variant doc): + * re-reads the linked-address and IBAN state from the Monerium API per active + * account and alerts on ANY divergence from the DB record (IBAN moved, new address + * linked). Vortex holds the whitelabel credentials, so association changes cannot + * be prevented client-side — only detected. + * 4. Config reconciliation (manifest re-verification, R07): re-reads per-clone config + * and clone bytecode. destination/fallbackAddress changes are owner-authorized by + * construction (`onlyFallback` in the contract) — they are reconciled into the DB + * and logged, not alarmed. feeBps/bytecode/registration drift is an incident. + * + * None of these monitors hold keys or send transactions; they are detection-only. + */ + +/** Uniswap V3 QuoterV2 on Ethereum mainnet (the pinned quoting contract, PRD §7.4). */ +export const MAINNET_QUOTER_V2: Address = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"; + +/** Stranding marker armed longer than this warns (the keeper converts within minutes normally). */ +export const STRANDED_WARN_MS = 12 * 60 * 60 * 1000; + +/** Full monitoring pass at most this often (the worker cycles every minute). */ +const MONITORING_INTERVAL_MS = 30 * 60_000; + +const quoterV2Abi = parseAbi([ + "function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)" +]); + +const chainlinkAbi = parseAbi([ + "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)" +]); + +// Read-only getters beyond the keeper ABI surface in ./chain.ts. +const forwarderMonitoringAbi = parseAbi([ + "function destination() view returns (address)", + "function fallbackAddress() view returns (address)", + "function feeBps() view returns (uint16)", + "function EURC() view returns (address)", + "function USDC() view returns (address)", + "function ORACLE() view returns (address)", + "function ORACLE_DECIMALS() view returns (uint8)", + "function SLIPPAGE_BPS() view returns (uint16)", + "function TRIGGER_DELAY() view returns (uint256)", + "function POOL_FEE_EURE_EURC() view returns (uint24)", + "function POOL_FEE_EURC_USDC() view returns (uint24)" +]); + +const factoryMonitoringAbi = parseAbi([ + "function implementation() view returns (address)", + "function isForwarder(address forwarder) view returns (bool)" +]); + +// ------------------------------------------------------------------ pure logic + +/** + * Price impact of an executable quote vs the Chainlink EUR/USD rate, in bps (floored; + * negative when the quote beats the oracle). Same scaling as VortexForwarder._minOut + * without the slippage haircut: EURe 18 dp in, USDC 6 dp out. + */ +export function computeQuoteImpactBps( + amountInRaw: bigint, + quotedOutRaw: bigint, + oracleAnswer: bigint, + oracleDecimals: number +): number { + const expectedOut = (amountInRaw * oracleAnswer) / 10n ** BigInt(12 + oracleDecimals); + if (expectedOut <= 0n) { + return 0; + } + return Number(((expectedOut - quotedOutRaw) * 10_000n) / expectedOut); +} + +export type StrandingSeverity = "error" | "ok" | "warn"; + +/** + * Severity of an armed stranding marker (R03): older than TRIGGER_DELAY (the + * permissionless-trigger delay) is an error; older than STRANDED_WARN_MS a warning. + */ +export function classifyStranding(strandedSinceSec: bigint, triggerDelaySec: bigint, nowMs: number): StrandingSeverity { + if (strandedSinceSec === 0n) { + return "ok"; + } + const armedMs = nowMs - Number(strandedSinceSec) * 1000; + if (armedMs >= Number(triggerDelaySec) * 1000) { + return "error"; + } + if (armedMs >= STRANDED_WARN_MS) { + return "warn"; + } + return "ok"; +} + +export interface AssociationDbRecord { + forwarderAddress: string; + iban: string | null; +} + +export interface LiveAssociationState { + /** All IBANs visible to the partner context: { iban, address } pairs. */ + ibans: { address: string; iban: string }[]; + /** Addresses linked to this account's profile. */ + profileAddresses: string[]; +} + +export function normalizeIban(iban: string): string { + return iban.replace(/\s+/g, "").toUpperCase(); +} + +/** + * Detects ANY divergence between the DB association record and the live Monerium + * state (S1/PATCH-ibans detective control): forwarder unlinked, extra addresses on + * the profile, the IBAN moved to another address, or an IBAN we did not record. + */ +export function diffAssociation(db: AssociationDbRecord, live: LiveAssociationState): string[] { + const changes: string[] = []; + const forwarder = db.forwarderAddress.toLowerCase(); + + if (!live.profileAddresses.some(address => address.toLowerCase() === forwarder)) { + changes.push(`forwarder ${db.forwarderAddress} is no longer linked to the profile`); + } + for (const address of live.profileAddresses) { + if (address.toLowerCase() !== forwarder) { + changes.push(`unexpected address linked to the profile: ${address}`); + } + } + + const dbIban = db.iban ? normalizeIban(db.iban) : null; + if (dbIban) { + const entry = live.ibans.find(candidate => normalizeIban(candidate.iban) === dbIban); + if (!entry) { + changes.push(`IBAN ${db.iban} no longer exists at Monerium`); + } else if (entry.address.toLowerCase() !== forwarder) { + changes.push(`IBAN ${db.iban} moved to address ${entry.address}`); + } + } + for (const entry of live.ibans) { + if (entry.address.toLowerCase() === forwarder && normalizeIban(entry.iban) !== dbIban) { + changes.push(`unrecorded IBAN issued for the forwarder: ${entry.iban}`); + } + } + return changes; +} + +export interface ForwarderConfigRecord { + destination: string; + fallbackAddress: string; + feeBps: number; +} + +export interface ConfigDriftResult { + /** Immutable-config violations — should be impossible; alarm, never reconcile. */ + errors: string[]; + /** destination/fallbackAddress drift: owner-authorized by construction (R07) — reconcile the DB. */ + 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`), so any change there is an expected owner-authorized transition + * (re-review R07); feeBps is immutable post-init, so a change there is an incident. + */ +export function detectConfigDrift(db: ForwarderConfigRecord, onchain: ForwarderConfigRecord): ConfigDriftResult { + const result: ConfigDriftResult = { errors: [], ownerAuthorizedUpdates: {} }; + if (db.feeBps !== onchain.feeBps) { + result.errors.push(`immutable feeBps mismatch: db=${db.feeBps} chain=${onchain.feeBps}`); + } + if (db.destination.toLowerCase() !== onchain.destination.toLowerCase()) { + result.ownerAuthorizedUpdates.destination = onchain.destination; + } + if (db.fallbackAddress.toLowerCase() !== onchain.fallbackAddress.toLowerCase()) { + result.ownerAuthorizedUpdates.fallbackAddress = onchain.fallbackAddress; + } + return result; +} + +/** Runtime code of a standard EIP-1167 minimal proxy pointing at `implementation`. */ +export function eip1167RuntimeCode(implementation: Address): Hex { + return `0x363d3d373d3d3d363d73${implementation.slice(2).toLowerCase()}5af43d82803e903d91602b57fd5bf3` as Hex; +} + +// ------------------------------------------------------------------ monitor runners + +async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise { + return MoneriumAccount.findAll({ where: { status: { [Op.in]: statuses } } }); +} + +/** + * Executable-depth check (PRD §7.4): QuoterV2 static quotes at minSwapAmount and + * perSwapCap on the pinned path vs Chainlink. Runs only against Ethereum mainnet — + * MAINNET_QUOTER_V2 is a mainnet pin. + */ +export async function runExecutableDepthCheck(): Promise { + if ((await getChainId()) !== 1) { + return; + } + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const forwarder = accounts[0].forwarderAddress as Address; + const { eure, factory } = await getForwarderImmutables(forwarder); + const [eurc, usdc, oracle, oracleDecimals, slippageBps, poolFeeEureEurc, poolFeeEurcUsdc, minSwapAmount, perSwapCap] = + await Promise.all([ + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "EURC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "USDC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "ORACLE" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "ORACLE_DECIMALS" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "SLIPPAGE_BPS" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "POOL_FEE_EURE_EURC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "POOL_FEE_EURC_USDC" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) + ]); + + const [, answer, , updatedAt] = await client.readContract({ + abi: chainlinkAbi, + address: oracle, + functionName: "latestRoundData" + }); + if (answer <= 0n) { + logger.error(`monerium-b2b: depth check aborted — Chainlink EUR/USD answered ${answer}`); + return; + } + + const path = encodePacked( + ["address", "uint24", "address", "uint24", "address"], + [eure, poolFeeEureEurc, eurc, poolFeeEurcUsdc, usdc] + ); + const quote = async (amountIn: bigint): Promise => { + const { result } = await client.simulateContract({ + abi: quoterV2Abi, + address: MAINNET_QUOTER_V2, + args: [path, amountIn], + functionName: "quoteExactInput" + }); + return result[0]; + }; + + const [minOut, capOut] = await Promise.all([quote(minSwapAmount), quote(perSwapCap)]); + const minImpactBps = computeQuoteImpactBps(minSwapAmount, minOut, answer, Number(oracleDecimals)); + const capImpactBps = computeQuoteImpactBps(perSwapCap, capOut, answer, Number(oracleDecimals)); + const detail = + `oracle=${answer} (updatedAt=${updatedAt}), minSwapAmount=${minSwapAmount} -> ${minOut} (${minImpactBps} bps), ` + + `perSwapCap=${perSwapCap} -> ${capOut} (${capImpactBps} bps), SLIPPAGE_BPS=${slippageBps}`; + + if (minImpactBps > slippageBps) { + // PAUSE THRESHOLD (PRD §7.4): even minimum-size swaps would revert on minOut. + logger.error( + "monerium-b2b: PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS; engage guardian pause per " + + `docs/runbooks/monerium-b2b-incident.md. ${detail}` + ); + } else if (capImpactBps > slippageBps) { + logger.warn(`monerium-b2b: executable depth below perSwapCap — cap-sized swaps would revert on minOut. ${detail}`); + } else { + logger.info(`monerium-b2b: depth check ok. ${detail}`); + } +} + +/** Stranded-balance monitor: armed R03 markers older than 12h warn, older than TRIGGER_DELAY error. */ +export async function runStrandedBalanceMonitor(now: number = Date.now()): Promise { + const accounts = await monitoredAccounts([ + MoneriumAccountStatus.Onboarding, + MoneriumAccountStatus.Active, + MoneriumAccountStatus.Suspended + ]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const { factory } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const [minSwapFloor, triggerDelay] = await Promise.all([ + client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), + client.readContract({ + abi: forwarderMonitoringAbi, + address: accounts[0].forwarderAddress as Address, + functionName: "TRIGGER_DELAY" + }) + ]); + + for (const account of accounts) { + const forwarder = account.forwarderAddress as Address; + const { eure } = await getForwarderImmutables(forwarder); + const [balance, strandedSince] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }) + ]); + if (balance < minSwapFloor) { + continue; + } + const severity = classifyStranding(strandedSince, triggerDelay, now); + if (severity === "ok") { + continue; + } + const hours = Math.floor((now - Number(strandedSince) * 1000) / 3_600_000); + const message = + `monerium-b2b: stranded EURe on forwarder ${forwarder} (account ${account.id}): balance=${balance}, ` + + `marker armed ${hours}h ago${severity === "error" ? " — past TRIGGER_DELAY, permissionless trigger is live" : ""}`; + if (severity === "error") { + logger.error(message); + } else { + logger.warn(message); + } + } +} + +/** + * Association monitor (S1 detective control): compares the Monerium-side linked + * addresses + IBAN state per active account against the DB record and alerts on ANY + * change. Error-level: an unexplained association change is an incident trigger + * (docs/runbooks/monerium-b2b-incident.md). + */ +export async function runAssociationMonitor(): Promise { + const accounts = await monitoredAccounts([MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const ibans = (await listIbans()).map(entry => ({ address: entry.address, iban: entry.iban })); + for (const account of accounts) { + try { + const profileAddresses = await getProfileAddresses(account.profileId); + const changes = diffAssociation( + { forwarderAddress: account.forwarderAddress, iban: account.iban }, + { ibans, profileAddresses } + ); + if (changes.length > 0) { + logger.error( + `monerium-b2b: ASSOCIATION CHANGE for account ${account.id} (profile ${account.profileId}): ${changes.join("; ")}` + ); + } + } catch (error) { + logger.warn(`monerium-b2b: association monitor failed for account ${account.id}:`, error); + } + } +} + +/** + * Config reconciliation (manifest re-verification pass, R07): re-checks per-clone + * state against the DB. Owner-authorized destination/fallback changes are reconciled + * (DB update + configVersion bump), immutable violations are alarmed. + */ +export async function runConfigReconciliation(): Promise { + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const implementationByFactory = new Map(); + + for (const account of accounts) { + try { + const forwarder = account.forwarderAddress as Address; + const { factory } = await getForwarderImmutables(forwarder); + let implementation = implementationByFactory.get(factory.toLowerCase()); + if (!implementation) { + implementation = await client.readContract({ + abi: factoryMonitoringAbi, + address: factory, + functionName: "implementation" + }); + implementationByFactory.set(factory.toLowerCase(), implementation); + } + + const [destination, fallbackAddress, feeBps, isForwarder, code] = await Promise.all([ + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "destination" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "fallbackAddress" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "feeBps" }), + client.readContract({ abi: factoryMonitoringAbi, address: factory, args: [forwarder], functionName: "isForwarder" }), + client.getCode({ address: forwarder }) + ]); + + if (!isForwarder) { + logger.error(`monerium-b2b: forwarder ${forwarder} (account ${account.id}) is not registered on factory ${factory}`); + } + if ((code ?? "0x").toLowerCase() !== eip1167RuntimeCode(implementation).toLowerCase()) { + logger.error( + `monerium-b2b: forwarder ${forwarder} (account ${account.id}) bytecode is not the EIP-1167 clone of ${implementation}` + ); + } + + const drift = detectConfigDrift( + { destination: account.destination, fallbackAddress: account.fallbackAddress, feeBps: account.feeBps }, + { destination, fallbackAddress, feeBps: Number(feeBps) } + ); + for (const error of drift.errors) { + logger.error(`monerium-b2b: config violation on forwarder ${forwarder} (account ${account.id}): ${error}`); + } + if (Object.keys(drift.ownerAuthorizedUpdates).length > 0) { + // Owner-authorized transition (R07): only the client's fallbackAddress can + // change these on-chain — reconcile, do not alarm. + await account.update({ ...drift.ownerAuthorizedUpdates, configVersion: account.configVersion + 1 }); + logger.warn( + `monerium-b2b: reconciled owner-authorized config change on forwarder ${forwarder} (account ${account.id}): ` + + `${JSON.stringify(drift.ownerAuthorizedUpdates)} (configVersion -> ${account.configVersion})` + ); + } + } catch (error) { + logger.warn(`monerium-b2b: config reconciliation failed for account ${account.id}:`, error); + } + } +} + +// ------------------------------------------------------------------ pass orchestration + +let lastPassAt = 0; + +export function resetMonitoringStateForTests(): void { + lastPassAt = 0; +} + +async function guarded(name: string, run: () => Promise): Promise { + try { + await run(); + } catch (error) { + logger.error(`monerium-b2b: ${name} failed:`, error); + } +} + +/** + * Runs the monitors at most every MONITORING_INTERVAL_MS. Chain monitors need only + * MONERIUM_B2B_RPC_URL (no keys); the association monitor needs the whitelabel API + * credentials. Each monitor is skipped, never fatal, when its config is absent. + */ +export async function runMonitoringPass(now: number = Date.now()): Promise { + if (now - lastPassAt < MONITORING_INTERVAL_MS) { + return; + } + lastPassAt = now; + if (config.moneriumB2b.rpcUrl) { + await guarded("executable-depth check", runExecutableDepthCheck); + await guarded("stranded-balance monitor", () => runStrandedBalanceMonitor(now)); + await guarded("config reconciliation", runConfigReconciliation); + } + if (config.moneriumB2b.clientId && config.moneriumB2b.clientSecret) { + await guarded("association monitor", runAssociationMonitor); + } +} diff --git a/apps/api/src/api/services/monerium-b2b/webhook.test.ts b/apps/api/src/api/services/monerium-b2b/webhook.test.ts new file mode 100644 index 000000000..90748d80b --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/webhook.test.ts @@ -0,0 +1,182 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import crypto from "crypto"; +// Value copies taken before the mock.module calls below; restored in afterAll because bun +// module mocks are process-wide and would poison later test files. +import * as webhookEventNamespace from "../../../models/moneriumWebhookEvent.model"; +import * as depositProcessorNamespace from "./deposit-processor"; + +const webhookEventReal = { ...webhookEventNamespace }; +const depositProcessorReal = { ...depositProcessorNamespace }; + +const callOrder: string[] = []; +const bulkCreate = mock(async (_rows: unknown, _options: unknown) => { + callOrder.push("insert"); + return []; +}); +const processInbox = mock(async () => 0); + +mock.module("../../../models/moneriumWebhookEvent.model", () => ({ + default: { bulkCreate } +})); +mock.module("./deposit-processor", () => ({ + ...depositProcessorReal, + processMoneriumWebhookInbox: processInbox +})); + +let webhook: typeof import("./webhook"); +let controller: typeof import("../../controllers/monerium-b2b.controller"); +let config: typeof import("../../../config/vars").config; + +const SECRET = "test-webhook-secret"; + +function sign(rawBody: Buffer, secret: string, encoding: "base64" | "hex" = "hex"): string { + return crypto.createHmac("sha256", secret).update(rawBody).digest(encoding); +} + +function mockRequest(rawBody: Buffer | undefined, signature: string | undefined): never { + return { + header: (name: string) => (name.toLowerCase() === "webhook-signature" ? signature : undefined), + rawBody + } as never; +} + +function mockResponse(): { json: ReturnType; status: ReturnType } { + const res = { + json: mock((_value: unknown) => { + callOrder.push("respond"); + return res; + }), + status: mock((_code: number) => res) + }; + return res; +} + +async function flushSetImmediate(): Promise { + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); +} + +beforeAll(async () => { + webhook = await import("./webhook"); + controller = await import("../../controllers/monerium-b2b.controller"); + ({ config } = await import("../../../config/vars")); +}); + +beforeEach(() => { + config.moneriumB2b.webhookSecret = SECRET; + bulkCreate.mockClear(); + processInbox.mockClear(); + callOrder.length = 0; +}); + +afterAll(() => { + mock.module("../../../models/moneriumWebhookEvent.model", () => ({ ...webhookEventReal })); + mock.module("./deposit-processor", () => ({ ...depositProcessorReal })); + mock.restore(); +}); + +describe("verifyWebhookSignature", () => { + const body = Buffer.from(JSON.stringify({ data: { id: "order-1" }, type: "order.updated" }), "utf8"); + + it("accepts a correct HMAC-SHA256 in hex or base64 encoding", () => { + expect(webhook.verifyWebhookSignature(body, sign(body, SECRET, "hex"), SECRET)).toBe(true); + expect(webhook.verifyWebhookSignature(body, sign(body, SECRET, "base64"), SECRET)).toBe(true); + }); + + it("rejects a wrong secret, tampered bytes, missing header, and empty secret", () => { + expect(webhook.verifyWebhookSignature(body, sign(body, "other-secret"), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(Buffer.concat([body, Buffer.from(" ")]), sign(body, SECRET), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, undefined, SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, "", SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, sign(body, SECRET), "")).toBe(false); + expect(webhook.verifyWebhookSignature(body, "not-a-mac", SECRET)).toBe(false); + }); +}); + +describe("deriveEventId", () => { + it("uses a top-level payload id when present", () => { + expect(webhook.deriveEventId(Buffer.from("{}"), { id: "evt-1" })).toBe("evt-1"); + }); + + it("falls back to a digest of the raw bytes, stable across redeliveries", () => { + const raw = Buffer.from('{"type":"order.updated"}'); + const first = webhook.deriveEventId(raw, { type: "order.updated" }); + expect(first).toStartWith("sha256:"); + expect(webhook.deriveEventId(Buffer.from(raw), { type: "order.updated" })).toBe(first); + expect(webhook.deriveEventId(Buffer.from('{"type":"order.created"}'), { type: "order.created" })).not.toBe(first); + }); +}); + +describe("recordWebhookEvent", () => { + it("inserts with on-conflict-do-nothing dedup semantics", async () => { + await webhook.recordWebhookEvent("evt-1", { type: "order.updated" }); + expect(bulkCreate).toHaveBeenCalledTimes(1); + const [rows, options] = bulkCreate.mock.calls[0] as [unknown, unknown]; + expect(rows).toEqual([{ eventId: "evt-1", payload: { type: "order.updated" } }]); + expect(options).toEqual({ ignoreDuplicates: true }); + }); +}); + +describe("POST /v1/monerium-b2b/webhook controller", () => { + const payload = { data: { id: "order-1" }, timestamp: "2026-07-17T00:00:00Z", type: "order.updated" }; + const rawBody = Buffer.from(JSON.stringify(payload), "utf8"); + + it("persists the delivery durably before responding 200 and processes async", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(bulkCreate).toHaveBeenCalledTimes(1); + // Durable insert strictly precedes the 200 (R06). + expect(callOrder).toEqual(["insert", "respond"]); + + await flushSetImmediate(); + expect(processInbox).toHaveBeenCalledTimes(1); + }); + + it("rejects an invalid signature with 401 and never touches the inbox", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, "wrong-secret")), res as never, next as never); + + expect(next).toHaveBeenCalledTimes(1); + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 401 }); + expect(bulkCreate).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects when the raw body was not captured", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(undefined, sign(rawBody, SECRET)), res as never, next as never); + + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 401 }); + expect(bulkCreate).not.toHaveBeenCalled(); + }); + + it("responds 503 when the webhook secret is not configured", async () => { + config.moneriumB2b.webhookSecret = ""; + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 503 }); + expect(bulkCreate).not.toHaveBeenCalled(); + }); + + it("acks a redelivery with 200 (insert is a dedup no-op)", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenNthCalledWith(2, 200); + // Same event id both times — the unique index makes the second insert a no-op. + const firstRows = bulkCreate.mock.calls[0]?.[0] as Array<{ eventId: string }>; + const secondRows = bulkCreate.mock.calls[1]?.[0] as Array<{ eventId: string }>; + expect(firstRows[0].eventId).toBe(secondRows[0].eventId); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/webhook.ts b/apps/api/src/api/services/monerium-b2b/webhook.ts new file mode 100644 index 000000000..b600f451f --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/webhook.ts @@ -0,0 +1,56 @@ +import crypto from "crypto"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; + +/** + * Monerium B2B webhook authentication + durable inbox (plan §3, R06). + * + * Monerium docs: each notification carries a `webhook-signature` header containing the + * HMAC-SHA256 of the minified JSON payload under the shared secret. We verify over the + * RAW request bytes exactly as delivered (never a re-serialization) with a + * constant-time compare. + * + * TODO(sandbox): the docs do not state the digest encoding — hex and base64 are both + * accepted here until the G0 sandbox spike pins it (both encode the same secret MAC, + * so accepting either does not widen the trust surface). + */ + +export const MONERIUM_SIGNATURE_HEADER = "webhook-signature"; + +function constantTimeEquals(a: Buffer, b: Buffer): boolean { + if (a.length !== b.length) { + // Compare against self to keep timing independent of the mismatch position. + crypto.timingSafeEqual(a, a); + return false; + } + return crypto.timingSafeEqual(a, b); +} + +export function verifyWebhookSignature(rawBody: Buffer, signatureHeader: string | undefined, secret: string): boolean { + if (!secret || !signatureHeader) return false; + const mac = crypto.createHmac("sha256", secret).update(rawBody).digest(); + const provided = Buffer.from(signatureHeader.trim(), "utf8"); + const hexMatch = constantTimeEquals(provided, Buffer.from(mac.toString("hex"), "utf8")); + const base64Match = constantTimeEquals(provided, Buffer.from(mac.toString("base64"), "utf8")); + return hexMatch || base64Match; +} + +/** + * Dedup identity for a delivery. Monerium's documented payload (`type`, `timestamp`, + * `data`) carries no delivery id, so redeliveries are identified by the digest of the + * raw bytes; a top-level `id` is honored if the payload ever grows one. + * TODO(sandbox): confirm whether deliveries carry an id field or header. + */ +export function deriveEventId(rawBody: Buffer, payload: unknown): string { + const id = (payload as { id?: unknown } | null)?.id; + if (typeof id === "string" && id.length > 0 && id.length <= 128) return id; + return `sha256:${crypto.createHash("sha256").update(rawBody).digest("hex")}`; +} + +/** + * Durably persists a delivery BEFORE the webhook responds 200. `ignoreDuplicates` + * compiles to `ON CONFLICT DO NOTHING` on the unique event_id — a redelivery is a + * silent no-op, and the caller still acks with 200. + */ +export async function recordWebhookEvent(eventId: string, payload: unknown): Promise { + await MoneriumWebhookEvent.bulkCreate([{ eventId, payload }], { ignoreDuplicates: true }); +} diff --git a/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts b/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts new file mode 100644 index 000000000..e36fc5935 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts @@ -0,0 +1,235 @@ +import httpStatus from "http-status"; +import { config } from "../../../config/vars"; +import { APIError } from "../../errors/api-error"; +import { LINK_MESSAGE } from "./attestor"; + +/** + * Thin Monerium whitelabel API client (client-credentials flow, sandbox-first). + * Endpoints per docs.monerium.com/api: POST /auth/token, POST /profiles, + * POST /addresses, POST /ibans, GET /ibans, GET /orders/{orderId}. + * Only the endpoints the B2B onramp needs — no speculative surface. + */ + +const FETCH_TIMEOUT_MS = 10_000; +const TOKEN_EXPIRY_SKEW_MS = 30_000; +const API_V2_ACCEPT = "application/vnd.monerium.api-v2+json"; + +export type MoneriumProfileKind = "personal" | "corporate"; + +export interface WhitelabelProfile { + id: string; + kind: MoneriumProfileKind; + state: string; +} + +export interface WhitelabelIban { + iban: string; + bic?: string; + address: string; + chain: string; +} + +export interface WhitelabelOrder { + id: string; + state: string; + kind?: string; + amount?: string; + currency?: string; + address?: string; +} + +interface CachedToken { + accessToken: string; + expiresAt: number; +} + +let cachedToken: CachedToken | null = null; +let tokenRequest: Promise | null = null; + +function upstreamError(_internalMessage: string): APIError { + return new APIError({ message: "Monerium request failed", status: httpStatus.BAD_GATEWAY }); +} + +async function fetchToken(): Promise { + const { apiUrl, clientId, clientSecret } = config.moneriumB2b; + let response: Response; + try { + response = await fetch(`${apiUrl}/auth/token`, { + body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "client_credentials" }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }); + } catch { + throw upstreamError("Monerium token request timed out or failed"); + } + if (!response.ok) { + throw upstreamError(`Monerium token endpoint returned HTTP ${response.status}`); + } + const token = (await response.json().catch(() => null)) as { access_token?: unknown; expires_in?: unknown } | null; + if (!token || typeof token.access_token !== "string" || typeof token.expires_in !== "number" || token.expires_in <= 0) { + throw upstreamError("Monerium returned an invalid token response"); + } + return { accessToken: token.access_token, expiresAt: Date.now() + token.expires_in * 1000 }; +} + +async function getAccessToken(forceRefresh = false): Promise { + if (!config.moneriumB2b.clientId || !config.moneriumB2b.clientSecret) { + throw new APIError({ + message: "Monerium B2B client credentials are not configured", + status: httpStatus.SERVICE_UNAVAILABLE + }); + } + if (!forceRefresh && cachedToken && cachedToken.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) { + return cachedToken.accessToken; + } + if (!tokenRequest) { + tokenRequest = fetchToken() + .then(token => { + cachedToken = token; + return token; + }) + .finally(() => { + tokenRequest = null; + }); + } + return (await tokenRequest).accessToken; +} + +async function request(path: string, init: { body?: unknown; method: "GET" | "POST" }): Promise { + const doFetch = async (accessToken: string): Promise => { + try { + return await fetch(`${config.moneriumB2b.apiUrl}${path}`, { + body: init.body === undefined ? undefined : JSON.stringify(init.body), + headers: { + Accept: API_V2_ACCEPT, + Authorization: `Bearer ${accessToken}`, + ...(init.body === undefined ? {} : { "Content-Type": "application/json" }) + }, + method: init.method, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }); + } catch { + throw upstreamError("Monerium request timed out or failed"); + } + }; + + let response = await doFetch(await getAccessToken()); + if (response.status === 401) { + // Client-credentials tokens are not refreshable — a 401 means expired/revoked; mint a new one once. + response = await doFetch(await getAccessToken(true)); + } + if (!response.ok) { + throw upstreamError(`Monerium returned HTTP ${response.status} for ${init.method} ${path}`); + } + if (response.status === 204) return null; + try { + return await response.json(); + } catch { + throw upstreamError("Monerium returned invalid JSON"); + } +} + +/** POST /profiles — partner-created profile (whitelabel). */ +export async function createProfile(kind: MoneriumProfileKind): Promise { + const profile = (await request("/profiles", { body: { kind }, method: "POST" })) as Record; + if (!profile || typeof profile.id !== "string" || profile.kind !== kind || typeof profile.state !== "string") { + throw upstreamError("Monerium returned an invalid profile"); + } + return { id: profile.id, kind, state: profile.state }; +} + +/** + * Corporate KYB submission for a whitelabel profile. + * + * Deliberately a stub: the KYB mechanism under whitelabel (Monerium-run verification vs + * KYC-reliance) is pending the MSA negotiation — deferred-decisions registry item T3. + * Do not build a speculative payload shape against it. + */ +export async function submitKybData(_profileId: string, _data: unknown): Promise { + throw new APIError({ + message: "Monerium B2B KYB submission is not implemented (pending registry item T3)", + status: httpStatus.NOT_IMPLEMENTED + }); +} + +/** + * POST /addresses — links a forwarder address to a profile using the attestor's + * EIP-1271-verifiable signature over the fixed link message (see ./attestor.ts). + */ +export async function linkAddress(profileId: string, address: string, chain: string, signature: string): Promise { + return request("/addresses", { + body: { address, chain, message: LINK_MESSAGE, profile: profileId, signature }, + method: "POST" + }); +} + +/** POST /ibans — requests IBAN issuance for a linked address. */ +export async function requestIban(address: string, chain: string): Promise { + return request("/ibans", { body: { address, chain }, method: "POST" }); +} + +/** GET /ibans — all IBANs visible to the partner context (association monitor + lookups). */ +export async function listIbans(): Promise { + const response = (await request("/ibans", { method: "GET" })) as { ibans?: unknown } | unknown[] | null; + const entries = Array.isArray(response) ? response : Array.isArray(response?.ibans) ? response.ibans : []; + const ibans: WhitelabelIban[] = []; + for (const entry of entries as Record[]) { + if (typeof entry?.iban === "string" && typeof entry.address === "string") { + ibans.push({ + address: entry.address, + bic: typeof entry.bic === "string" ? entry.bic : undefined, + chain: typeof entry.chain === "string" ? entry.chain : "", + iban: entry.iban + }); + } + } + return ibans; +} + +/** GET /ibans — returns the IBAN issued for an address, or null if none yet. */ +export async function getIbanForAddress(address: string): Promise { + const ibans = await listIbans(); + return ibans.find(entry => entry.address.toLowerCase() === address.toLowerCase()) ?? null; +} + +/** + * GET /addresses?profile={id} — the addresses linked to a profile. Used by the + * association monitor (S1 detective control): any address linked to a client profile + * beyond the forwarder is an alert condition. + */ +export async function getProfileAddresses(profileId: string): Promise { + const response = (await request(`/addresses?profile=${encodeURIComponent(profileId)}`, { method: "GET" })) as + | { addresses?: unknown } + | unknown[] + | null; + const entries = Array.isArray(response) ? response : Array.isArray(response?.addresses) ? response.addresses : []; + const addresses: string[] = []; + for (const entry of entries as Record[]) { + if (typeof entry?.address === "string") { + addresses.push(entry.address); + } + } + return addresses; +} + +/** GET /orders/{orderId} */ +export async function getOrder(orderId: string): Promise { + const order = (await request(`/orders/${encodeURIComponent(orderId)}`, { method: "GET" })) as Record; + if (!order || typeof order.id !== "string" || typeof order.state !== "string") { + throw upstreamError("Monerium returned an invalid order"); + } + return { + address: typeof order.address === "string" ? order.address : undefined, + amount: typeof order.amount === "string" ? order.amount : undefined, + currency: typeof order.currency === "string" ? order.currency : undefined, + id: order.id, + kind: typeof order.kind === "string" ? order.kind : undefined, + state: order.state + }; +} + +export function resetMoneriumB2bClientForTests(): void { + cachedToken = null; + tokenRequest = null; +} diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts new file mode 100644 index 000000000..b7c33f726 --- /dev/null +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -0,0 +1,127 @@ +import { CronJob } from "cron"; +import { Op } from "sequelize"; +import { Address } from "viem"; +import logger from "../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../models/moneriumAccount.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; +import { erc20Abi, getForwarderImmutables, getPublicClient, isKeeperChainConfigured } from "../services/monerium-b2b/chain"; +import { runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; +import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { runDormancyGate } from "../services/monerium-b2b/dormancy"; +import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; +import { runMonitoringPass } from "../services/monerium-b2b/monitoring"; + +const DEFAULT_CRON_TIME = "* * * * *"; // every minute + +/** + * Keeper loop for the Monerium B2B onramp (plan §3): webhook inbox -> mint watcher -> + * per-account conversion executor -> dormancy gate (R05). Chain steps are skipped + * (inbox processing still runs) until MONERIUM_B2B_RPC_URL and + * MONERIUM_B2B_KEEPER_PRIVATE_KEY are configured. + */ +class MoneriumB2bWorker { + private job: CronJob; + private running = false; + private chainConfigWarned = false; + + constructor(cronTime = DEFAULT_CRON_TIME) { + this.job = new CronJob(cronTime, this.cycle.bind(this), null, false, undefined, null, true); + } + + public start(): void { + logger.info("Starting Monerium B2B keeper worker"); + this.job.start(); + } + + public stop(): void { + logger.info("Stopping Monerium B2B keeper worker"); + this.job.stop(); + } + + private async cycle(): Promise { + if (this.running) { + return; // previous cycle (e.g. waiting on a receipt) still in progress + } + this.running = true; + try { + await processMoneriumWebhookInbox(); + + if (!isKeeperChainConfigured()) { + if (!this.chainConfigWarned) { + this.chainConfigWarned = true; + logger.warn( + "monerium-b2b: MONERIUM_B2B_RPC_URL / MONERIUM_B2B_KEEPER_PRIVATE_KEY not configured — keeper chain steps disabled" + ); + } + } else { + const mintedAccountIds = await runMintWatcher(); + const candidateIds = await this.conversionCandidates(mintedAccountIds); + for (const accountId of candidateIds) { + try { + await runConversionExecutor(accountId); + } catch (error) { + logger.error(`monerium-b2b: conversion executor failed for account ${accountId}:`, error); + } + } + + await runDormancyGate(); + } + + // Detection-only monitors (plan D3); internally rate-limited and gated on the + // read RPC / API credentials, so this is safe to call every cycle. + await runMonitoringPass(); + } catch (error) { + logger.error("Error during Monerium B2B keeper cycle:", error); + } finally { + this.running = false; + } + } + + /** + * Accounts worth running the executor for: fresh mints from this cycle, accounts with + * minted-but-unallocated deposits, and accounts whose forwarder holds a nonzero EURe + * balance (covers inflows the watcher has not indexed yet). + */ + private async conversionCandidates(mintedAccountIds: string[]): Promise { + const candidates = new Set(mintedAccountIds); + + const unallocated = await MoneriumFiatDeposit.findAll({ + attributes: ["accountId"], + group: ["account_id"], + where: { allocatedExecutionId: null, status: MoneriumFiatDepositStatus.Minted } + }); + for (const row of unallocated) { + candidates.add(row.accountId); + } + + const balanceCheckAccounts = await MoneriumAccount.findAll({ + where: { + dormantSince: null, + // Sequelize renders an empty NOT IN as NOT IN (NULL), which matches nothing. + ...(candidates.size > 0 ? { id: { [Op.notIn]: [...candidates] } } : {}), + status: { [Op.in]: [MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active] } + } + }); + for (const account of balanceCheckAccounts) { + try { + const forwarder = account.forwarderAddress as Address; + const { eure } = await getForwarderImmutables(forwarder); + const balance = await getPublicClient().readContract({ + abi: erc20Abi, + address: eure, + args: [forwarder], + functionName: "balanceOf" + }); + if (balance > 0n) { + candidates.add(account.id); + } + } catch (error) { + logger.warn(`monerium-b2b: balance check failed for account ${account.id}:`, error); + } + } + + return [...candidates]; + } +} + +export default MoneriumB2bWorker; diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index ac15fcb1a..e1b94875f 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -78,7 +78,18 @@ app.use(requestContext); app.use(morgan(logs)); // parse body params and attach them to req.body -app.use(bodyParser.json({ limit: REQUEST_BODY_LIMIT })); +app.use( + bodyParser.json({ + limit: REQUEST_BODY_LIMIT, + // The Monerium B2B webhook HMAC is computed over the RAW request bytes; capture + // them before JSON parsing for that route only (monerium-b2b.controller). + verify: (req, _res, buf) => { + if (req.url?.startsWith("/v1/monerium-b2b/webhook")) { + (req as typeof req & { rawBody?: Buffer }).rawBody = buf; + } + } + }) +); app.use(bodyParser.urlencoded({ extended: true, limit: REQUEST_BODY_LIMIT })); // gzip compression diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 5b3886069..803061161 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -167,6 +167,19 @@ interface Config { clientId: string; redirectUri: string; }; + // B2B whitelabel onramp integration (docs/prd/monerium-b2b-implementation-plan.md §3). + // Separate credential set from the legacy consumer OAuth integration above. + moneriumB2b: { + apiUrl: string; + attestorPrivateKey: string | undefined; + clientId: string; + clientSecret: string; + guardianPrivateKey: string | undefined; + keeperPrivateKey: string | undefined; + privateRpcUrl: string | undefined; + rpcUrl: string | undefined; + webhookSecret: string; + }; subscanApiKey: string | undefined; vortexFeePenPercentage: number; @@ -232,6 +245,23 @@ export const config: Config = { clientId: process.env.MONERIUM_CLIENT_ID || "", redirectUri: process.env.MONERIUM_REDIRECT_URI || "http://localhost:5174/monerium/callback" }, + moneriumB2b: { + // Sandbox by default: the B2B build is developed against api.monerium.dev until the + // MSA is signed (locked scope decision 2026-07-17). + apiUrl: process.env.MONERIUM_B2B_API_URL || "https://api.monerium.dev", + attestorPrivateKey: process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, + clientId: process.env.MONERIUM_B2B_CLIENT_ID || "", + clientSecret: process.env.MONERIUM_B2B_CLIENT_SECRET || "", + // Dormancy-gate pause key (guardian on the factory/forwarders). Distinct from the + // keeper and attestor keys by design; unset = log-only mode for the dormancy gate. + guardianPrivateKey: process.env.MONERIUM_B2B_GUARDIAN_PRIVATE_KEY, + keeperPrivateKey: process.env.MONERIUM_B2B_KEEPER_PRIVATE_KEY, + // Private-orderflow submission endpoint (e.g. https://rpc.flashbots.net); when unset + // the keeper falls back to the public RPC and logs a warning (see chain.ts). + privateRpcUrl: process.env.MONERIUM_B2B_PRIVATE_RPC_URL, + rpcUrl: process.env.MONERIUM_B2B_RPC_URL, + webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" + }, mykobo: { feeFallback: readMykoboFeeFallback() }, diff --git a/apps/api/src/database/migrations/051-monerium-b2b-onramp-tables.ts b/apps/api/src/database/migrations/051-monerium-b2b-onramp-tables.ts new file mode 100644 index 000000000..3a7f6d81d --- /dev/null +++ b/apps/api/src/database/migrations/051-monerium-b2b-onramp-tables.ts @@ -0,0 +1,104 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// B2B zero-touch onramp persistence (docs/prd/monerium-b2b-implementation-plan.md §3). +// Deliberately separate from ramp_states: a Monerium IBAN account is permanent and +// repeatedly funded, not a one-shot ramp. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("monerium_accounts", { + config_version: { allowNull: false, defaultValue: 1, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + destination: { allowNull: false, type: DataTypes.STRING(42) }, + dormant_since: { allowNull: true, type: DataTypes.DATE }, + fallback_address: { allowNull: false, type: DataTypes.STRING(42) }, + fee_bps: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + forwarder_address: { allowNull: false, type: DataTypes.STRING(42), unique: true }, + iban: { allowNull: true, type: DataTypes.STRING(42) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + profile_id: { allowNull: false, type: DataTypes.STRING(64), unique: true }, + status: { + allowNull: false, + defaultValue: "onboarding", + type: DataTypes.ENUM("onboarding", "active", "suspended", "closed") + }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + await queryInterface.addIndex("monerium_accounts", ["status"]); + + await queryInterface.createTable("monerium_fiat_deposits", { + account_id: { + allowNull: false, + references: { key: "id", model: "monerium_accounts" }, + type: DataTypes.UUID + }, + allocated_execution_id: { allowNull: true, type: DataTypes.UUID }, + amount_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + block_hash: { allowNull: true, type: DataTypes.STRING(66) }, + chain_id: { allowNull: true, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + currency: { allowNull: false, defaultValue: "eur", type: DataTypes.STRING(8) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + log_index: { allowNull: true, type: DataTypes.INTEGER }, + monerium_order_id: { allowNull: false, type: DataTypes.STRING(64), unique: true }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.ENUM("pending", "minted", "held", "returned") + }, + tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + await queryInterface.addIndex("monerium_fiat_deposits", ["account_id", "status"]); + // On-chain identity: one deposit per mint log (partial unique — mint fields are null + // until the Transfer is observed). + await queryInterface.sequelize.query( + "CREATE UNIQUE INDEX monerium_fiat_deposits_mint_log ON monerium_fiat_deposits (chain_id, tx_hash, log_index) WHERE tx_hash IS NOT NULL" + ); + + await queryInterface.createTable("monerium_conversion_executions", { + account_id: { + allowNull: false, + references: { key: "id", model: "monerium_accounts" }, + type: DataTypes.UUID + }, + block_number: { allowNull: true, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + destination: { allowNull: false, type: DataTypes.STRING(42) }, + error: { allowNull: true, type: DataTypes.TEXT }, + eure_in_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + fee_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.ENUM("pending", "confirmed", "failed") + }, + tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + usdc_gross_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + usdc_net_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) } + }); + await queryInterface.addIndex("monerium_conversion_executions", ["account_id", "status"]); + + // Durable webhook inbox: persist-before-200 with payload, dedup by delivery id (R06). + await queryInterface.createTable("monerium_webhook_events", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + event_id: { allowNull: false, type: DataTypes.STRING(128), unique: true }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + payload: { allowNull: false, type: DataTypes.JSONB }, + processed_at: { allowNull: true, type: DataTypes.DATE } + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("monerium_webhook_events"); + await queryInterface.dropTable("monerium_conversion_executions"); + await queryInterface.dropTable("monerium_fiat_deposits"); + await queryInterface.dropTable("monerium_accounts"); + for (const enumName of [ + "enum_monerium_accounts_status", + "enum_monerium_fiat_deposits_status", + "enum_monerium_conversion_executions_status" + ]) { + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "${enumName}"`); + } +} diff --git a/apps/api/src/database/migrations/052-monerium-keeper-chain-state.ts b/apps/api/src/database/migrations/052-monerium-keeper-chain-state.ts new file mode 100644 index 000000000..e96c025e9 --- /dev/null +++ b/apps/api/src/database/migrations/052-monerium-keeper-chain-state.ts @@ -0,0 +1,25 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Keeper chain state for the B2B onramp (docs/prd/monerium-b2b-implementation-plan.md §3): +// - monerium_chain_cursors: persisted getLogs cursors for the poll-based EURe mint +// watcher, keyed by watcher name (one row per watcher+chain). +// - monerium_fiat_deposits.block_number: the mint block, required by the R04 attribution +// rule ("mint block <= execution block"); blockHash alone cannot be compared. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("monerium_chain_cursors", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + last_block: { allowNull: false, type: DataTypes.BIGINT }, + name: { primaryKey: true, type: DataTypes.STRING(64) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + + await queryInterface.addColumn("monerium_fiat_deposits", "block_number", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_fiat_deposits", "block_number"); + await queryInterface.dropTable("monerium_chain_cursors"); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3d9c73e53..dbf02be9e 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,7 @@ import registerPhaseHandlers from "./api/services/phases/register-handlers"; import { priceFeedService } from "./api/services/priceFeed.service"; import ApiClientEventsRetentionWorker from "./api/workers/api-client-events-retention.worker"; import CleanupWorker from "./api/workers/cleanup.worker"; +import MoneriumB2bWorker from "./api/workers/monerium-b2b.worker"; import RampRecoveryWorker from "./api/workers/ramp-recovery.worker"; import UnhandledPaymentWorker from "./api/workers/unhandled-payment.worker"; @@ -66,6 +67,7 @@ const initializeApp = async () => { new ApiClientEventsRetentionWorker().start(); new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); + new MoneriumB2bWorker().start(); // Start AlfredPay limits refresh loop (daily; falls back to hardcoded if stale) AlfredpayLimitsService.getInstance().start(); diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index bd152a569..38ae9c7e7 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -5,6 +5,11 @@ import ApiKey from "./apiKey.model"; import CustomerEntity from "./customerEntity.model"; import KycCase from "./kycCase.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; +import MoneriumAccount from "./moneriumAccount.model"; +import MoneriumChainCursor from "./moneriumChainCursor.model"; +import MoneriumConversionExecution from "./moneriumConversionExecution.model"; +import MoneriumFiatDeposit from "./moneriumFiatDeposit.model"; +import MoneriumWebhookEvent from "./moneriumWebhookEvent.model"; import Notification from "./notification.model"; import NotificationPreference from "./notificationPreference.model"; import Partner from "./partner.model"; @@ -22,6 +27,10 @@ import User from "./user.model"; import Webhook from "./webhook.model"; // Define associations +MoneriumAccount.hasMany(MoneriumFiatDeposit, { as: "fiatDeposits", foreignKey: "accountId" }); +MoneriumFiatDeposit.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); +MoneriumAccount.hasMany(MoneriumConversionExecution, { as: "conversionExecutions", foreignKey: "accountId" }); +MoneriumConversionExecution.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); RampState.belongsTo(QuoteTicket, { as: "quote", foreignKey: "quoteId" }); QuoteTicket.hasOne(RampState, { as: "rampState", foreignKey: "quoteId" }); QuoteTicket.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); @@ -100,6 +109,11 @@ const models = { CustomerEntity, KycCase, MaintenanceSchedule, + MoneriumAccount, + MoneriumChainCursor, + MoneriumConversionExecution, + MoneriumFiatDeposit, + MoneriumWebhookEvent, Notification, NotificationPreference, Partner, diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts new file mode 100644 index 000000000..681436957 --- /dev/null +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -0,0 +1,127 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumAccountStatus { + Onboarding = "onboarding", + Active = "active", + Suspended = "suspended", + Closed = "closed" +} + +// Persistent B2B onramp account (docs/prd/monerium-b2b-implementation-plan.md §3): +// one row per client = one Monerium profile + IBAN + deployed forwarder. Long-lived, +// repeatedly funded — deliberately NOT a RampState. +export interface MoneriumAccountAttributes { + id: string; + profileId: string; + iban: string | null; + forwarderAddress: string; + destination: string; + fallbackAddress: string; + feeBps: number; + configVersion: number; + status: MoneriumAccountStatus; + dormantSince: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumAccountCreationAttributes = Optional< + MoneriumAccountAttributes, + "id" | "iban" | "configVersion" | "status" | "dormantSince" | "createdAt" | "updatedAt" +>; + +class MoneriumAccount + extends Model + implements MoneriumAccountAttributes +{ + declare id: string; + declare profileId: string; + declare iban: string | null; + declare forwarderAddress: string; + declare destination: string; + declare fallbackAddress: string; + declare feeBps: number; + declare configVersion: number; + declare status: MoneriumAccountStatus; + declare dormantSince: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumAccount.init( + { + configVersion: { + allowNull: false, + defaultValue: 1, + field: "config_version", + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + destination: { + allowNull: false, + type: DataTypes.STRING(42) + }, + dormantSince: { + allowNull: true, + field: "dormant_since", + type: DataTypes.DATE + }, + fallbackAddress: { + allowNull: false, + field: "fallback_address", + type: DataTypes.STRING(42) + }, + feeBps: { + allowNull: false, + defaultValue: 0, + field: "fee_bps", + type: DataTypes.INTEGER + }, + forwarderAddress: { + allowNull: false, + field: "forwarder_address", + type: DataTypes.STRING(42), + unique: true + }, + iban: { + allowNull: true, + type: DataTypes.STRING(42) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + profileId: { + allowNull: false, + field: "profile_id", + type: DataTypes.STRING(64), + unique: true + }, + status: { + allowNull: false, + defaultValue: MoneriumAccountStatus.Onboarding, + type: DataTypes.ENUM(...Object.values(MoneriumAccountStatus)) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [{ fields: ["status"] }], + modelName: "MoneriumAccount", + sequelize, + tableName: "monerium_accounts" + } +); + +export default MoneriumAccount; diff --git a/apps/api/src/models/moneriumChainCursor.model.ts b/apps/api/src/models/moneriumChainCursor.model.ts new file mode 100644 index 000000000..42d224fa5 --- /dev/null +++ b/apps/api/src/models/moneriumChainCursor.model.ts @@ -0,0 +1,58 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Persisted block cursor for the poll-based mint watcher (one row per watcher name, +// e.g. "eure-mints:1"). lastBlock is the highest block already scanned; the next run +// resumes at lastBlock + 1 so a crash between getLogs and processing re-scans rather +// than skips (deposit writes are idempotent via the mint-log unique index). +export interface MoneriumChainCursorAttributes { + name: string; + lastBlock: string; // BIGINT, stringified + createdAt: Date; + updatedAt: Date; +} + +type MoneriumChainCursorCreationAttributes = Optional; + +class MoneriumChainCursor + extends Model + implements MoneriumChainCursorAttributes +{ + declare name: string; + declare lastBlock: string; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumChainCursor.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + lastBlock: { + allowNull: false, + field: "last_block", + type: DataTypes.BIGINT + }, + name: { + primaryKey: true, + type: DataTypes.STRING(64) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + modelName: "MoneriumChainCursor", + sequelize, + tableName: "monerium_chain_cursors" + } +); + +export default MoneriumChainCursor; diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts new file mode 100644 index 000000000..6e88dbc34 --- /dev/null +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -0,0 +1,129 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumConversionExecutionStatus { + Pending = "pending", + Confirmed = "confirmed", + Failed = "failed" +} + +// One row per swapAndForward execution (or intentional batch). Allocation to deposits +// is snapshot-based (plan §3, R04): included deposits are those with mint block <= +// execution block not yet allocated; pro-rata by amount, remainder to largest. +export interface MoneriumConversionExecutionAttributes { + id: string; + accountId: string; + eureInRaw: string; // 18-decimal base units + usdcGrossRaw: string | null; // 6-decimal base units + feeRaw: string | null; + usdcNetRaw: string | null; + destination: string; + txHash: string | null; + blockNumber: number | null; + status: MoneriumConversionExecutionStatus; + error: string | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumConversionExecutionCreationAttributes = Optional< + MoneriumConversionExecutionAttributes, + "id" | "usdcGrossRaw" | "feeRaw" | "usdcNetRaw" | "txHash" | "blockNumber" | "status" | "error" | "createdAt" | "updatedAt" +>; + +class MoneriumConversionExecution + extends Model + implements MoneriumConversionExecutionAttributes +{ + declare id: string; + declare accountId: string; + declare eureInRaw: string; + declare usdcGrossRaw: string | null; + declare feeRaw: string | null; + declare usdcNetRaw: string | null; + declare destination: string; + declare txHash: string | null; + declare blockNumber: number | null; + declare status: MoneriumConversionExecutionStatus; + declare error: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumConversionExecution.init( + { + accountId: { + allowNull: false, + field: "account_id", + type: DataTypes.UUID + }, + blockNumber: { + allowNull: true, + field: "block_number", + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + destination: { + allowNull: false, + type: DataTypes.STRING(42) + }, + error: { + allowNull: true, + type: DataTypes.TEXT + }, + eureInRaw: { + allowNull: false, + field: "eure_in_raw", + type: DataTypes.DECIMAL(38, 0) + }, + feeRaw: { + allowNull: true, + field: "fee_raw", + type: DataTypes.DECIMAL(38, 0) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: MoneriumConversionExecutionStatus.Pending, + type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionStatus)) + }, + txHash: { + allowNull: true, + field: "tx_hash", + type: DataTypes.STRING(66) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + usdcGrossRaw: { + allowNull: true, + field: "usdc_gross_raw", + type: DataTypes.DECIMAL(38, 0) + }, + usdcNetRaw: { + allowNull: true, + field: "usdc_net_raw", + type: DataTypes.DECIMAL(38, 0) + } + }, + { + indexes: [{ fields: ["account_id", "status"] }], + modelName: "MoneriumConversionExecution", + sequelize, + tableName: "monerium_conversion_executions" + } +); + +export default MoneriumConversionExecution; diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts new file mode 100644 index 000000000..7721fc608 --- /dev/null +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -0,0 +1,154 @@ +import { DataTypes, Model, Op, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumFiatDepositStatus { + Pending = "pending", + Minted = "minted", + Held = "held", + Returned = "returned" +} + +// One row per Monerium issue order (SEPA deposit → EURe mint). Identity/idempotency: +// monerium_order_id for accounting, (chain_id, tx_hash, log_index) for the on-chain +// mint. Status transitions are forward-only (plan §3, R06/R13). +export interface MoneriumFiatDepositAttributes { + id: string; + accountId: string; + moneriumOrderId: string; + amountRaw: string; // EURe base units (18 decimals), stringified + currency: string; + status: MoneriumFiatDepositStatus; + chainId: number | null; + txHash: string | null; + logIndex: number | null; + blockHash: string | null; + blockNumber: number | null; + allocatedExecutionId: string | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumFiatDepositCreationAttributes = Optional< + MoneriumFiatDepositAttributes, + | "id" + | "status" + | "chainId" + | "txHash" + | "logIndex" + | "blockHash" + | "blockNumber" + | "allocatedExecutionId" + | "createdAt" + | "updatedAt" +>; + +class MoneriumFiatDeposit + extends Model + implements MoneriumFiatDepositAttributes +{ + declare id: string; + declare accountId: string; + declare moneriumOrderId: string; + declare amountRaw: string; + declare currency: string; + declare status: MoneriumFiatDepositStatus; + declare chainId: number | null; + declare txHash: string | null; + declare logIndex: number | null; + declare blockHash: string | null; + declare blockNumber: number | null; + declare allocatedExecutionId: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumFiatDeposit.init( + { + accountId: { + allowNull: false, + field: "account_id", + type: DataTypes.UUID + }, + allocatedExecutionId: { + allowNull: true, + field: "allocated_execution_id", + type: DataTypes.UUID + }, + amountRaw: { + allowNull: false, + field: "amount_raw", + type: DataTypes.DECIMAL(38, 0) + }, + blockHash: { + allowNull: true, + field: "block_hash", + type: DataTypes.STRING(66) + }, + // Mint block, set by the mint watcher; the R04 attribution rule compares it to the + // execution block (docs/prd/monerium-b2b-implementation-plan.md §3). + blockNumber: { + allowNull: true, + field: "block_number", + type: DataTypes.INTEGER + }, + chainId: { + allowNull: true, + field: "chain_id", + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + currency: { + allowNull: false, + defaultValue: "eur", + type: DataTypes.STRING(8) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + logIndex: { + allowNull: true, + field: "log_index", + type: DataTypes.INTEGER + }, + moneriumOrderId: { + allowNull: false, + field: "monerium_order_id", + type: DataTypes.STRING(64), + unique: true + }, + status: { + allowNull: false, + defaultValue: MoneriumFiatDepositStatus.Pending, + type: DataTypes.ENUM(...Object.values(MoneriumFiatDepositStatus)) + }, + txHash: { + allowNull: true, + field: "tx_hash", + type: DataTypes.STRING(66) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { fields: ["account_id", "status"] }, + { fields: ["chain_id", "tx_hash", "log_index"], unique: true, where: { tx_hash: { [Op.ne]: null } } } + ], + modelName: "MoneriumFiatDeposit", + sequelize, + tableName: "monerium_fiat_deposits" + } +); + +export default MoneriumFiatDeposit; diff --git a/apps/api/src/models/moneriumWebhookEvent.model.ts b/apps/api/src/models/moneriumWebhookEvent.model.ts new file mode 100644 index 000000000..fa1cc2574 --- /dev/null +++ b/apps/api/src/models/moneriumWebhookEvent.model.ts @@ -0,0 +1,66 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Durable inbox for Monerium B2B webhook deliveries (plan §3, R06): rows are inserted +// (dedup on event_id, on conflict do nothing) BEFORE the webhook returns 200 and +// processed asynchronously afterwards. Table created by migration 051. +export interface MoneriumWebhookEventAttributes { + id: string; + eventId: string; + payload: unknown; + processedAt: Date | null; + createdAt: Date; +} + +type MoneriumWebhookEventCreationAttributes = Optional; + +class MoneriumWebhookEvent + extends Model + implements MoneriumWebhookEventAttributes +{ + declare id: string; + declare eventId: string; + declare payload: unknown; + declare processedAt: Date | null; + declare createdAt: Date; +} + +MoneriumWebhookEvent.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + eventId: { + allowNull: false, + field: "event_id", + type: DataTypes.STRING(128), + unique: true + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + payload: { + allowNull: false, + type: DataTypes.JSONB + }, + processedAt: { + allowNull: true, + field: "processed_at", + type: DataTypes.DATE + } + }, + { + modelName: "MoneriumWebhookEvent", + sequelize, + tableName: "monerium_webhook_events", + // The inbox table has no updated_at column (append + processed_at flip only). + updatedAt: false + } +); + +export default MoneriumWebhookEvent; diff --git a/contracts/README.md b/contracts/README.md index 23423fb7c..ae5a70c9b 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -15,3 +15,4 @@ This directory contains smart-contract projects managed as Bun workspaces. ## Current projects - `relayer/` - Relayer contract project (Hardhat) +- `monerium-forwarder/` - Attestor-linked forwarder for the Monerium B2B onramp (Foundry) diff --git a/contracts/monerium-forwarder/.gitignore b/contracts/monerium-forwarder/.gitignore new file mode 100644 index 000000000..83f939c72 --- /dev/null +++ b/contracts/monerium-forwarder/.gitignore @@ -0,0 +1,3 @@ +out/ +cache/ +broadcast/ diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md new file mode 100644 index 000000000..5532e407a --- /dev/null +++ b/contracts/monerium-forwarder/README.md @@ -0,0 +1,47 @@ +# Monerium B2B Onramp Forwarder + +Foundry project for the attestor-linked forwarder (Monerium B2B zero-touch onramp): +per-client EIP-1167 clones whose EIP-1271 `isValidSignature` accepts only the fixed +Monerium link message from the Vortex attestor, with an immutable EURe→EURC→USDC +conversion policy and client-controlled recovery. + +- Spec: [docs/prd/monerium-b2b-implementation-plan.md](../../docs/prd/monerium-b2b-implementation-plan.md) §2 + and [docs/prd/monerium-eur-usdc-onramp-b2b-variant.md](../../docs/prd/monerium-eur-usdc-onramp-b2b-variant.md) +- All placeholder parameter values (slippage, delays, caps, fee) are tracked in + [docs/prd/monerium-onramp-deferred-decisions.md](../../docs/prd/monerium-onramp-deferred-decisions.md) — + do not treat values in code as final. + +```bash +git submodule update --init # once per clone: fetches lib/forge-std +forge build +forge test # unit tests (mocks) +ETH_RPC_URL=... forge test # + mainnet fork tests (pending, task 2) +``` + +Linted by `forge fmt`/forge-lint, not Biome (the TypeScript in `script/` is Biome-governed). + +## Config manifest (D3) + +`script/generate-manifest.ts` emits a versioned JSON manifest for a factory deployment +(bytecode hashes, all immutables, per-clone config, deploy-tx provenance); +`script/verify-manifest.ts` re-checks every field against live chain state and exits +nonzero with a field-level diff on mismatch. Runnable by third parties from a repo +checkout (`bun install` once for viem): + +```bash +bun script/generate-manifest.ts [outFile] [--logs-rpc ] +bun script/verify-manifest.ts [--logs-rpc ] +``` + +`--logs-rpc`: many free public RPCs refuse historical `eth_getLogs` ("archive" gating); +pass a logs-capable endpoint for the ForwarderDeployed enumeration — all other reads, +including per-forwarder deploy provenance via transaction receipts, work on any full +node. Published manifests live in `manifests/`. + +**The manifest is consistency evidence, NOT a trust root** (re-review R01): it is +produced by Vortex from the same chain state it attests to, so a verifier pass proves +only that the deployment has not silently changed since publication — not that it was +honest. Independent verification of contract behavior requires the verified source on a +block explorer. Client-authorized config changes (destination/fallback rotation by the +client's own fallbackAddress) are reported as expected transitions, not failures +(re-review R07). diff --git a/contracts/monerium-forwarder/foundry.lock b/contracts/monerium-forwarder/foundry.lock new file mode 100644 index 000000000..31b0fe2e2 --- /dev/null +++ b/contracts/monerium-forwarder/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.2", + "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" + } + } +} \ No newline at end of file diff --git a/contracts/monerium-forwarder/foundry.toml b/contracts/monerium-forwarder/foundry.toml new file mode 100644 index 000000000..0835e8dbf --- /dev/null +++ b/contracts/monerium-forwarder/foundry.toml @@ -0,0 +1,14 @@ +[profile.default] +src = "src" +out = "out" +test = "test" +libs = ["lib"] +solc_version = "0.8.26" +optimizer = true +optimizer_runs = 10000 + +[profile.default.fuzz] +runs = 512 + +# Mainnet fork tests (task 2) read this endpoint from env: +# ETH_RPC_URL must be set; fork tests are skipped without it. diff --git a/contracts/monerium-forwarder/lib/forge-std b/contracts/monerium-forwarder/lib/forge-std new file mode 160000 index 000000000..bf647bd60 --- /dev/null +++ b/contracts/monerium-forwarder/lib/forge-std @@ -0,0 +1 @@ +Subproject commit bf647bd6046f2f7da30d0c2bf435e5c76a780c1b diff --git a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json new file mode 100644 index 000000000..bd54e30c1 --- /dev/null +++ b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json @@ -0,0 +1,65 @@ +{ + "chainId": 11155111, + "factory": { + "address": "0xcBE354e847bF597513148918E7EbDff72aC75842", + "immutables": { + "CAP_CEILING": "50000000000000000000000", + "implementation": "0x7e1c653CaAFCa44258d8680B09F42a33475504a9", + "MIN_SWAP_FLOOR": "1000000000000000000" + }, + "operational": { + "globalPaused": false, + "guardian": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + "minSwapAmount": "25000000000000000000", + "perSwapCap": "10000000000000000000000" + }, + "runtimeBytecodeHash": "0x3a651b091d179f24618becebeba26698ac13e366899e08f5f03ba8cbac879e98" + }, + "forwarders": [ + { + "address": "0xD7444AB7270A142227Fe659D63873ABdc8AF9b72", + "clientMutable": { + "destination": "0x1111111111111111111111111111111111111111", + "fallbackAddress": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1" + }, + "deploy": { + "blockNumber": "11293035", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000002", + "txHash": "0x90e67a4f9698c9d009687eaaedd2d8b89eebfa104a2edb4c7923fa7a033f2787" + }, + "immutables": { + "feeBps": 0, + "isForwarder": true + }, + "runtimeBytecodeHash": "0x215c88056e67f5925d5660d5eb02a45dca8e69b50db65bacb5ba9ed44d63f364" + } + ], + "generatedAt": "2026-07-17T17:11:57.612Z", + "implementation": { + "address": "0x7e1c653CaAFCa44258d8680B09F42a33475504a9", + "immutables": { + "ATTESTOR": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + "EURC": "0x539fA90D0a29eB2a513f6b88fEd30b529dF071ca", + "EURE": "0x67b34b93ac295c985e856E5B8A20D83026b580Eb", + "FACTORY": "0xcBE354e847bF597513148918E7EbDff72aC75842", + "FEE_RECIPIENT": "0x3333333333333333333333333333333333333333", + "LINK_HASH_191": "0xb77c35c892a1b24b10a2ce49b424e578472333ee8d2456234fff90626332c50f", + "LINK_MESSAGE": "I hereby declare that I am the address owner.", + "MAX_FEE_BPS": 100, + "MAX_ORACLE_AGE": "187200", + "ORACLE": "0x337dd479435aE2593c9B023B48617278c6AB34E3", + "ORACLE_DECIMALS": 8, + "POOL_FEE_EURC_USDC": 500, + "POOL_FEE_EURE_EURC": 500, + "RECOVERY_HASH": "0x0000000000000000000000000000000000000000000000000000000000000000", + "ROUTER": "0x2222222222222222222222222222222222222222", + "SLIPPAGE_BPS": 100, + "SWEEP_DELAY": "5184000", + "TRIGGER_DELAY": "86400", + "USDC": "0xEc262C76Ff70330BBa90e2c477B185d6665c4aA2" + }, + "runtimeBytecodeHash": "0x0c362c605fc7a795c4cab02fca0751e27817caf84017296d8fa2325dde6f1d11" + }, + "manifestVersion": 1, + "purpose": "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). NOT a trust root (re-review R01): this file is produced by Vortex from the same chain state it attests to, so it proves only that the deployment has not silently changed since publication — not that it was honest. Verify contract behavior independently against the verified source on a block explorer." +} diff --git a/contracts/monerium-forwarder/package.json b/contracts/monerium-forwarder/package.json new file mode 100644 index 000000000..d507051b1 --- /dev/null +++ b/contracts/monerium-forwarder/package.json @@ -0,0 +1,13 @@ +{ + "description": "Attestor-linked forwarder contracts for the Monerium B2B zero-touch onramp (Foundry)", + "devDependencies": { + "viem": "catalog:" + }, + "name": "@vortexfi/contracts-monerium-forwarder", + "private": true, + "scripts": { + "compile": "forge build", + "test": "forge test" + }, + "version": "0.1.0" +} diff --git a/contracts/monerium-forwarder/script/generate-manifest.ts b/contracts/monerium-forwarder/script/generate-manifest.ts new file mode 100644 index 000000000..26cca7bad --- /dev/null +++ b/contracts/monerium-forwarder/script/generate-manifest.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env bun +import { writeFileSync } from "node:fs"; +import { Address, createPublicClient, http, isAddress, PublicClient } from "viem"; +import { + enumerateForwarders, + MANIFEST_PURPOSE, + MANIFEST_VERSION, + Manifest, + readCoreState, + readForwarderEntry +} from "./manifest-core"; + +/** + * Emits a versioned JSON config manifest for a VortexForwarderFactory deployment + * (Monerium B2B onramp, implementation plan D3): factory + implementation runtime + * bytecode hashes, all implementation-level immutables, and per-clone config with + * deploy transaction provenance (ForwarderDeployed events). + * + * The manifest is CONSISTENCY EVIDENCE, NOT A TRUST ROOT (re-review R01): it is + * produced by Vortex from the same chain state it attests to. Publishing it lets + * third parties detect silent changes (via verify-manifest.ts) — it does not prove + * the deployment was honest in the first place. + * + * Usage: + * bun script/generate-manifest.ts [outFile] [--logs-rpc ] + * + * Without [outFile] the manifest is printed to stdout (progress goes to stderr). + * --logs-rpc: many free public RPCs refuse historical eth_getLogs ("archive" gating); + * pass a logs-capable endpoint here for the ForwarderDeployed enumeration — every + * other read still goes through . + */ + +function parseArgs(argv: string[]): { factoryAddress: Address; logsRpcUrl?: string; outFile?: string; rpcUrl: string } { + const positional: string[] = []; + let logsRpcUrl: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--logs-rpc") { + logsRpcUrl = argv[++i]; + } else { + positional.push(argv[i]); + } + } + const [factoryAddress, rpcUrl, outFile] = positional; + if (!factoryAddress || !rpcUrl) { + console.error("Usage: bun script/generate-manifest.ts [outFile] [--logs-rpc ]"); + process.exit(2); + } + if (!isAddress(factoryAddress)) { + console.error(`error: ${factoryAddress} is not a valid address`); + process.exit(2); + } + return { factoryAddress: factoryAddress as Address, logsRpcUrl, outFile, rpcUrl }; +} + +async function main(): Promise { + const { factoryAddress, logsRpcUrl, outFile, rpcUrl } = parseArgs(process.argv.slice(2)); + + const client = createPublicClient({ transport: http(rpcUrl) }); + const logsClient: PublicClient = logsRpcUrl ? createPublicClient({ transport: http(logsRpcUrl) }) : client; + + console.error(`reading deployment state for factory ${factoryAddress} ...`); + const core = await readCoreState(client, factoryAddress); + const deployed = await enumerateForwarders(logsClient, factoryAddress); + const forwarders = []; + for (const { deploy, forwarder } of deployed) { + forwarders.push(await readForwarderEntry(client, factoryAddress, forwarder, deploy)); + } + const manifest: Manifest = { + ...core, + forwarders, + generatedAt: new Date().toISOString(), + manifestVersion: MANIFEST_VERSION, + purpose: MANIFEST_PURPOSE + }; + console.error( + `chainId ${manifest.chainId}, implementation ${manifest.implementation.address}, ${manifest.forwarders.length} forwarder(s)` + ); + + const json = `${JSON.stringify(manifest, null, 2)}\n`; + if (outFile) { + writeFileSync(outFile, json); + console.error(`manifest written to ${outFile}`); + } else { + process.stdout.write(json); + } +} + +main().catch(error => { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/contracts/monerium-forwarder/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts new file mode 100644 index 000000000..42aae9c48 --- /dev/null +++ b/contracts/monerium-forwarder/script/manifest-core.ts @@ -0,0 +1,416 @@ +import { Address, getAddress, Hex, keccak256, PublicClient, parseAbi, parseAbiItem, parseEventLogs } from "viem"; + +/** + * Shared chain-reading core for generate-manifest.ts / verify-manifest.ts. + * + * R01 (docs/prd/monerium-b2b-implementation-plan.md §5): the manifest produced from + * this state is CONSISTENCY EVIDENCE, NOT A TRUST ROOT. It is generated by Vortex from + * the same chain state it attests to, so it cannot prove the deployment was honest — + * only that the deployment has not silently changed since the manifest was published. + * Independent verification of WHAT the contracts do requires reading the verified + * source on a block explorer. + */ + +export const MANIFEST_VERSION = 1; + +export const MANIFEST_PURPOSE = + "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + + "NOT a trust root (re-review R01): this file is produced by Vortex from the same chain state it attests to, " + + "so it proves only that the deployment has not silently changed since publication — not that it was honest. " + + "Verify contract behavior independently against the verified source on a block explorer."; + +// ------------------------------------------------------------------ ABI surface +// Mirrors contracts/monerium-forwarder/src/VortexForwarder.sol + VortexForwarderFactory.sol. + +export const factoryAbi = parseAbi([ + "function implementation() view returns (address)", + "function MIN_SWAP_FLOOR() view returns (uint256)", + "function CAP_CEILING() view returns (uint256)", + "function guardian() view returns (address)", + "function globalPaused() view returns (bool)", + "function minSwapAmount() view returns (uint256)", + "function perSwapCap() view returns (uint256)", + "function isForwarder(address forwarder) view returns (bool)" +]); + +export const forwarderDeployedEvent = parseAbiItem( + "event ForwarderDeployed(address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt)" +); + +export const forwarderConfigAbi = parseAbi([ + "function destination() view returns (address)", + "function fallbackAddress() view returns (address)", + "function feeBps() view returns (uint16)" +]); + +export const implementationAbi = parseAbi([ + "function EURE() view returns (address)", + "function EURC() view returns (address)", + "function USDC() view returns (address)", + "function ROUTER() view returns (address)", + "function ORACLE() view returns (address)", + "function ORACLE_DECIMALS() view returns (uint8)", + "function FACTORY() view returns (address)", + "function ATTESTOR() view returns (address)", + "function FEE_RECIPIENT() view returns (address)", + "function MAX_ORACLE_AGE() view returns (uint256)", + "function SLIPPAGE_BPS() view returns (uint16)", + "function MAX_FEE_BPS() view returns (uint16)", + "function SWEEP_DELAY() view returns (uint256)", + "function TRIGGER_DELAY() view returns (uint256)", + "function POOL_FEE_EURE_EURC() view returns (uint24)", + "function POOL_FEE_EURC_USDC() view returns (uint24)", + "function LINK_HASH_191() view returns (bytes32)", + "function RECOVERY_HASH() view returns (bytes32)", + "function LINK_MESSAGE() view returns (string)" +]); + +// ------------------------------------------------------------------ manifest shape + +/** Implementation-level immutables shared by all clones (plan §2.1). */ +export interface ImplementationImmutables { + ATTESTOR: string; + EURC: string; + EURE: string; + FACTORY: string; + FEE_RECIPIENT: string; + LINK_HASH_191: Hex; + LINK_MESSAGE: string; + MAX_FEE_BPS: number; + MAX_ORACLE_AGE: string; + ORACLE: string; + ORACLE_DECIMALS: number; + POOL_FEE_EURC_USDC: number; + POOL_FEE_EURE_EURC: number; + RECOVERY_HASH: Hex; + ROUTER: string; + SLIPPAGE_BPS: number; + SWEEP_DELAY: string; + TRIGGER_DELAY: string; + USDC: string; +} + +export interface ForwarderManifestEntry { + address: string; + /** + * Mutable ONLY by the client's fallbackAddress (contract `onlyFallback`). A drift here + * is an owner-authorized state transition, not an incident (re-review R07): the + * verifier reports it as EXPECTED-TRANSITION and the manifest should be regenerated. + */ + clientMutable: { + destination: string; + fallbackAddress: string; + }; + deploy: { + blockNumber: string; + salt: Hex; + txHash: Hex; + }; + /** Set once in the deploy transaction; immutable afterwards. Mismatch = incident. */ + immutables: { + feeBps: number; + isForwarder: boolean; + }; + /** keccak256 of the clone's runtime code; must equal the EIP-1167 code for `implementation.address`. */ + runtimeBytecodeHash: Hex; +} + +export interface CoreState { + chainId: number; + factory: { + address: string; + immutables: { + CAP_CEILING: string; + MIN_SWAP_FLOOR: string; + implementation: string; + }; + /** + * Guardian-tunable within the immutable bounds (registry P6/P7) plus role/pause + * state. Drift here is legitimate operation: the verifier reports NOTICE, not + * failure. + */ + operational: { + globalPaused: boolean; + guardian: string; + minSwapAmount: string; + perSwapCap: string; + }; + runtimeBytecodeHash: Hex; + }; + implementation: { + address: string; + immutables: ImplementationImmutables; + runtimeBytecodeHash: Hex; + }; +} + +export interface Manifest extends CoreState { + forwarders: ForwarderManifestEntry[]; + generatedAt: string; + manifestVersion: number; + purpose: string; +} + +// ------------------------------------------------------------------ helpers + +/** Runtime code of a standard EIP-1167 minimal proxy pointing at `implementation`. */ +export function eip1167RuntimeCode(implementation: Address): Hex { + return `0x363d3d373d3d3d363d73${implementation.slice(2).toLowerCase()}5af43d82803e903d91602b57fd5bf3` as Hex; +} + +async function codeHash(client: PublicClient, address: Address): Promise { + const code = await client.getCode({ address }); + if (!code || code === "0x") { + throw new Error(`no contract code at ${address}`); + } + return keccak256(code); +} + +function read( + client: PublicClient, + abi: typeof factoryAbi | typeof implementationAbi | typeof forwarderConfigAbi, + address: Address, + functionName: string, + args: unknown[] = [] +): Promise { + // biome-ignore lint/suspicious/noExplicitAny: generic dispatcher over three hand-pinned ABIs + return client.readContract({ abi, address, args, functionName } as any) as Promise; +} + +// ------------------------------------------------------------------ deploy provenance + +export interface DeployProvenance { + blockNumber: bigint; + salt: Hex; + txHash: Hex; +} + +const LOG_CHUNK = 50_000n; + +/** + * Finds the factory's deploy block by binary search over getCode (needs an archive + * RPC); falls back to 0 when historical getCode is unavailable. + */ +async function findDeployBlock(client: PublicClient, factory: Address, latest: bigint): Promise { + try { + let lo = 0n; + let hi = latest; + while (lo < hi) { + const mid = (lo + hi) / 2n; + const code = await client.getCode({ address: factory, blockNumber: mid }); + if (code && code !== "0x") { + hi = mid; + } else { + lo = mid + 1n; + } + } + return lo; + } catch { + return 0n; + } +} + +/** + * Enumerates all forwarders ever deployed by the factory from its ForwarderDeployed + * events, oldest first. Needs an RPC that serves historical eth_getLogs (many free + * endpoints gate this behind archive plans — pass a logs-capable RPC to the scripts + * via --logs-rpc in that case). + */ +export async function enumerateForwarders( + client: PublicClient, + factoryAddress: Address +): Promise<{ deploy: DeployProvenance; forwarder: Address }[]> { + const factory = getAddress(factoryAddress); + const latest = await client.getBlockNumber(); + // biome-ignore lint/suspicious/noExplicitAny: viem log generics collapse across the two getLogs call shapes below + let rawLogs: any[]; + try { + // Single full-range query first — cheap when the provider allows it. + rawLogs = await client.getLogs({ address: factory, event: forwarderDeployedEvent, fromBlock: 0n, toBlock: latest }); + } catch { + const start = await findDeployBlock(client, factory, latest); + rawLogs = []; + for (let from = start; from <= latest; from += LOG_CHUNK) { + const to = from + LOG_CHUNK - 1n > latest ? latest : from + LOG_CHUNK - 1n; + rawLogs.push( + ...(await client.getLogs({ address: factory, event: forwarderDeployedEvent, fromBlock: from, toBlock: to })) + ); + } + } + return rawLogs + .filter(log => log.blockNumber !== null && log.transactionHash !== null) + .map(log => ({ + deploy: { + blockNumber: log.blockNumber as bigint, + salt: log.args.salt as Hex, + txHash: log.transactionHash as Hex + }, + forwarder: getAddress(log.args.forwarder as Address) + })); +} + +/** + * Re-derives a forwarder's deploy provenance from its deploy transaction receipt: the + * receipt must be successful and contain the factory's ForwarderDeployed event for the + * forwarder. Works on any full node (receipts by hash are not archive-gated), which is + * what lets third parties verify provenance through free public RPCs. + */ +export async function resolveDeployProvenance( + client: PublicClient, + factoryAddress: Address, + forwarderAddress: Address, + txHash: Hex +): Promise { + const receipt = await client.getTransactionReceipt({ hash: txHash }); + if (receipt.status !== "success") { + throw new Error(`deploy tx ${txHash} did not succeed`); + } + const events = parseEventLogs({ abi: [forwarderDeployedEvent], logs: receipt.logs }).filter( + log => + log.address.toLowerCase() === factoryAddress.toLowerCase() && + (log.args.forwarder as Address).toLowerCase() === forwarderAddress.toLowerCase() + ); + if (events.length === 0) { + throw new Error(`deploy tx ${txHash} contains no ForwarderDeployed event for ${forwarderAddress} from ${factoryAddress}`); + } + return { blockNumber: receipt.blockNumber, salt: events[0].args.salt as Hex, txHash }; +} + +// ------------------------------------------------------------------ state readers + +/** Reads chainId + factory + implementation sections from live chain state. */ +export async function readCoreState(client: PublicClient, factoryAddress: Address): Promise { + const factory = getAddress(factoryAddress); + const chainId = await client.getChainId(); + + const [implementation, minSwapFloor, capCeiling, guardian, globalPaused, minSwapAmount, perSwapCap] = await Promise.all([ + read
(client, factoryAbi, factory, "implementation"), + read(client, factoryAbi, factory, "MIN_SWAP_FLOOR"), + read(client, factoryAbi, factory, "CAP_CEILING"), + read
(client, factoryAbi, factory, "guardian"), + read(client, factoryAbi, factory, "globalPaused"), + read(client, factoryAbi, factory, "minSwapAmount"), + read(client, factoryAbi, factory, "perSwapCap") + ]); + + const [ + eure, + eurc, + usdc, + router, + oracle, + oracleDecimals, + implFactory, + attestor, + feeRecipient, + maxOracleAge, + slippageBps, + maxFeeBps, + sweepDelay, + triggerDelay, + poolFeeEureEurc, + poolFeeEurcUsdc, + linkHash191, + recoveryHash, + linkMessage + ] = await Promise.all([ + read
(client, implementationAbi, implementation, "EURE"), + read
(client, implementationAbi, implementation, "EURC"), + read
(client, implementationAbi, implementation, "USDC"), + read
(client, implementationAbi, implementation, "ROUTER"), + read
(client, implementationAbi, implementation, "ORACLE"), + read(client, implementationAbi, implementation, "ORACLE_DECIMALS"), + read
(client, implementationAbi, implementation, "FACTORY"), + read
(client, implementationAbi, implementation, "ATTESTOR"), + read
(client, implementationAbi, implementation, "FEE_RECIPIENT"), + read(client, implementationAbi, implementation, "MAX_ORACLE_AGE"), + read(client, implementationAbi, implementation, "SLIPPAGE_BPS"), + read(client, implementationAbi, implementation, "MAX_FEE_BPS"), + read(client, implementationAbi, implementation, "SWEEP_DELAY"), + read(client, implementationAbi, implementation, "TRIGGER_DELAY"), + read(client, implementationAbi, implementation, "POOL_FEE_EURE_EURC"), + read(client, implementationAbi, implementation, "POOL_FEE_EURC_USDC"), + read(client, implementationAbi, implementation, "LINK_HASH_191"), + read(client, implementationAbi, implementation, "RECOVERY_HASH"), + read(client, implementationAbi, implementation, "LINK_MESSAGE") + ]); + + return { + chainId, + factory: { + address: factory, + immutables: { + CAP_CEILING: capCeiling.toString(), + implementation: getAddress(implementation), + MIN_SWAP_FLOOR: minSwapFloor.toString() + }, + operational: { + globalPaused, + guardian: getAddress(guardian), + minSwapAmount: minSwapAmount.toString(), + perSwapCap: perSwapCap.toString() + }, + runtimeBytecodeHash: await codeHash(client, factory) + }, + implementation: { + address: getAddress(implementation), + immutables: { + ATTESTOR: getAddress(attestor), + EURC: getAddress(eurc), + EURE: getAddress(eure), + FACTORY: getAddress(implFactory), + FEE_RECIPIENT: getAddress(feeRecipient), + LINK_HASH_191: linkHash191, + LINK_MESSAGE: linkMessage, + MAX_FEE_BPS: Number(maxFeeBps), + MAX_ORACLE_AGE: maxOracleAge.toString(), + ORACLE: getAddress(oracle), + ORACLE_DECIMALS: Number(oracleDecimals), + POOL_FEE_EURC_USDC: Number(poolFeeEurcUsdc), + POOL_FEE_EURE_EURC: Number(poolFeeEureEurc), + RECOVERY_HASH: recoveryHash, + ROUTER: getAddress(router), + SLIPPAGE_BPS: Number(slippageBps), + SWEEP_DELAY: sweepDelay.toString(), + TRIGGER_DELAY: triggerDelay.toString(), + USDC: getAddress(usdc) + }, + runtimeBytecodeHash: await codeHash(client, implementation) + } + }; +} + +/** Reads one forwarder's live per-clone config + code hash into a manifest entry. */ +export async function readForwarderEntry( + client: PublicClient, + factoryAddress: Address, + forwarderAddress: Address, + deploy: DeployProvenance +): Promise { + const factory = getAddress(factoryAddress); + const forwarder = getAddress(forwarderAddress); + const [destination, fallbackAddress, feeBps, isForwarder, forwarderCodeHash] = await Promise.all([ + read
(client, forwarderConfigAbi, forwarder, "destination"), + read
(client, forwarderConfigAbi, forwarder, "fallbackAddress"), + read(client, forwarderConfigAbi, forwarder, "feeBps"), + read(client, factoryAbi, factory, "isForwarder", [forwarder]), + codeHash(client, forwarder) + ]); + return { + address: forwarder, + clientMutable: { + destination: getAddress(destination), + fallbackAddress: getAddress(fallbackAddress) + }, + deploy: { + blockNumber: deploy.blockNumber.toString(), + salt: deploy.salt, + txHash: deploy.txHash + }, + immutables: { + feeBps: Number(feeBps), + isForwarder + }, + runtimeBytecodeHash: forwarderCodeHash + }; +} diff --git a/contracts/monerium-forwarder/script/verify-manifest.ts b/contracts/monerium-forwarder/script/verify-manifest.ts new file mode 100644 index 000000000..4c300114a --- /dev/null +++ b/contracts/monerium-forwarder/script/verify-manifest.ts @@ -0,0 +1,211 @@ +#!/usr/bin/env bun +import { readFileSync } from "node:fs"; +import { Address, createPublicClient, Hex, http, keccak256, PublicClient } from "viem"; +import { + eip1167RuntimeCode, + enumerateForwarders, + ForwarderManifestEntry, + MANIFEST_VERSION, + Manifest, + readCoreState, + readForwarderEntry, + resolveDeployProvenance +} from "./manifest-core"; + +/** + * Re-checks every field of a published config manifest against live chain state and + * exits nonzero with a field-level diff on mismatch. Self-contained: needs only this + * repo checkout (`bun install` once for viem), the manifest file, and any public RPC — + * runnable by third parties: + * + * bun script/verify-manifest.ts [--logs-rpc ] + * + * Deploy provenance is verified from each forwarder's deploy transaction RECEIPT (the + * receipt must contain the factory's ForwarderDeployed event), which works on any full + * node. Only the completeness check — "no forwarders were deployed that the manifest + * does not list" — needs historical eth_getLogs; many free RPCs gate that behind + * archive plans, so pass --logs-rpc with a logs-capable endpoint or accept a NOTICE + * that completeness was not checked. + * + * What a PASS means — and what it does not (re-review R01): the manifest is + * CONSISTENCY EVIDENCE, NOT A TRUST ROOT. A pass proves the deployment still matches + * what Vortex published, i.e. nothing changed silently. It does NOT prove the + * published configuration was correct or honest — for that, read the verified + * contract source on a block explorer. + * + * Severity classes: + * FAIL immutable/bytecode/deploy-provenance mismatch -> exit 1 + * EXPECTED-TRANSITION clientMutable fields (destination/fallbackAddress) changed by + * the client's own fallbackAddress (`onlyFallback` in the + * contract). Owner-authorized, not an incident (re-review R07); + * regenerate + republish the manifest. exit 0 + * NOTICE guardian-tunable operational parameters, a stale forwarder + * list (new deployments since publication), or a skipped + * completeness check. exit 0 + */ + +type Severity = "FAIL" | "EXPECTED-TRANSITION" | "NOTICE"; + +interface Diff { + actual: string; + expected: string; + path: string; + severity: Severity; +} + +function flatten(value: unknown, prefix: string, out: Map): void { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const [key, child] of Object.entries(value)) { + flatten(child, prefix ? `${prefix}.${key}` : key, out); + } + return; + } + out.set(prefix, String(value)); +} + +function severityFor(path: string): Severity { + if (path.includes(".clientMutable.")) return "EXPECTED-TRANSITION"; + if (path.includes(".operational.")) return "NOTICE"; + return "FAIL"; +} + +function diffSection(path: string, expected: unknown, actual: unknown, diffs: Diff[]): void { + const expectedFlat = new Map(); + const actualFlat = new Map(); + flatten(expected, path, expectedFlat); + flatten(actual, path, actualFlat); + for (const key of new Set([...expectedFlat.keys(), ...actualFlat.keys()])) { + const expectedValue = expectedFlat.get(key) ?? ""; + const actualValue = actualFlat.get(key) ?? ""; + if (expectedValue !== actualValue) { + diffs.push({ actual: actualValue, expected: expectedValue, path: key, severity: severityFor(key) }); + } + } +} + +async function verifyForwarder( + client: PublicClient, + manifest: Manifest, + entry: ForwarderManifestEntry, + expectedCloneHash: Hex, + diffs: Diff[] +): Promise { + const factory = manifest.factory.address as Address; + const forwarder = entry.address as Address; + let live: ForwarderManifestEntry; + try { + // Provenance from the deploy tx receipt: must contain the factory's + // ForwarderDeployed event for this forwarder (works on any full node). + const provenance = await resolveDeployProvenance(client, factory, forwarder, entry.deploy.txHash); + live = await readForwarderEntry(client, factory, forwarder, provenance); + } catch (error) { + diffs.push({ + actual: `<${error instanceof Error ? error.message : String(error)}>`, + expected: "verifiable forwarder", + path: `forwarders.${entry.address}`, + severity: "FAIL" + }); + return; + } + diffSection(`forwarders.${entry.address}`, entry, live, diffs); + if (live.runtimeBytecodeHash.toLowerCase() !== expectedCloneHash.toLowerCase()) { + diffs.push({ + actual: live.runtimeBytecodeHash, + expected: expectedCloneHash, + path: `forwarders.${entry.address}.runtimeBytecodeHash (EIP-1167 for implementation)`, + severity: "FAIL" + }); + } +} + +async function checkCompleteness(logsClient: PublicClient, manifest: Manifest, diffs: Diff[]): Promise { + try { + const deployed = await enumerateForwarders(logsClient, manifest.factory.address as Address); + const listed = new Set(manifest.forwarders.map(entry => entry.address.toLowerCase())); + for (const { forwarder } of deployed) { + if (!listed.has(forwarder.toLowerCase())) { + // Deployed after publication: stale manifest, not tampering — regenerate. + diffs.push({ actual: forwarder, expected: "", path: `forwarders.${forwarder}`, severity: "NOTICE" }); + } + } + } catch { + diffs.push({ + actual: "", + expected: "ForwarderDeployed enumeration", + path: "forwarders.", + severity: "NOTICE" + }); + } +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const positional: string[] = []; + let logsRpcUrl: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--logs-rpc") { + logsRpcUrl = argv[++i]; + } else { + positional.push(argv[i]); + } + } + const [manifestFile, rpcUrl] = positional; + if (!manifestFile || !rpcUrl) { + console.error("Usage: bun script/verify-manifest.ts [--logs-rpc ]"); + process.exit(2); + } + + const manifest = JSON.parse(readFileSync(manifestFile, "utf8")) as Manifest; + if (manifest.manifestVersion !== MANIFEST_VERSION) { + console.error(`error: manifest version ${manifest.manifestVersion} is not supported (expected ${MANIFEST_VERSION})`); + process.exit(1); + } + + const client = createPublicClient({ transport: http(rpcUrl) }); + const logsClient: PublicClient = logsRpcUrl ? createPublicClient({ transport: http(logsRpcUrl) }) : client; + + console.error(`re-reading live state for factory ${manifest.factory.address} ...`); + const core = await readCoreState(client, manifest.factory.address as Address); + + const diffs: Diff[] = []; + diffSection("chainId", manifest.chainId, core.chainId, diffs); + diffSection("factory", manifest.factory, core.factory, diffs); + diffSection("implementation", manifest.implementation, core.implementation, diffs); + + // Every clone's runtime code must be the EIP-1167 proxy for the published + // implementation — checked against live state below. + const expectedCloneHash = keccak256(eip1167RuntimeCode(manifest.implementation.address as Address)); + for (const entry of manifest.forwarders) { + await verifyForwarder(client, manifest, entry, expectedCloneHash, diffs); + } + await checkCompleteness(logsClient, manifest, diffs); + + for (const diff of diffs) { + console.log(`[${diff.severity}] ${diff.path}: manifest=${diff.expected} live=${diff.actual}`); + } + + const failures = diffs.filter(diff => diff.severity === "FAIL").length; + const transitions = diffs.filter(diff => diff.severity === "EXPECTED-TRANSITION").length; + const notices = diffs.filter(diff => diff.severity === "NOTICE").length; + + if (failures > 0) { + console.log(`VERIFICATION FAILED: ${failures} mismatch(es), ${transitions} expected transition(s), ${notices} notice(s)`); + process.exit(1); + } + if (transitions > 0 || notices > 0) { + console.log( + `VERIFICATION PASSED with ${transitions} owner-authorized transition(s) and ${notices} notice(s) — ` + + "regenerate and republish the manifest to fold them in (consistency evidence only, NOT a trust root — R01)" + ); + return; + } + console.log( + `VERIFICATION PASSED: all ${manifest.forwarders.length} forwarder(s), implementation and factory match the manifest ` + + "(consistency evidence only, NOT a trust root — R01)" + ); +} + +main().catch(error => { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol new file mode 100644 index 000000000..f2baa1b56 --- /dev/null +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// Uniswap V3 SwapRouter02-style multi-hop interface (no deadline field; registry P10 +/// tracks the final router pin — if classic SwapRouter is chosen, add the deadline). +interface ISwapRouter02 { + struct ExactInputParams { + bytes path; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); +} + +interface AggregatorV3Interface { + function decimals() external view returns (uint8); + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} + +interface IVortexForwarderFactory { + function guardian() external view returns (address); + function isKeeper(address account) external view returns (bool); + function globalPaused() external view returns (bool); + function minSwapAmount() external view returns (uint256); + function perSwapCap() external view returns (uint256); + function MIN_SWAP_FLOOR() external view returns (uint256); +} + +/// @title VortexForwarder +/// @notice Per-client forwarding account for the Monerium B2B onramp +/// (docs/prd/monerium-eur-usdc-onramp-b2b-variant.md, implementation plan §2). +/// Deployed as an EIP-1167 clone by VortexForwarderFactory; the clone address is +/// linked to the client's Monerium profile, EURe mints land here, and the only +/// ways assets can ever leave are: +/// 1. the pinned EURe -> EURC -> USDC swap (oracle-checked minOut, output to self), +/// 2. USDC to the client's `destination` (plus fee <= feeBps to FEE_RECIPIENT), +/// 3. EURe to the client's `fallbackAddress` (delayed permissionless sweep), +/// 4. anything, by the client's `fallbackAddress` itself (`sweep`). +/// Vortex (guardian/keeper) can execute the policy, pause it, and nothing else. +/// @dev EIP-1271 is deliberately constrained to the fixed Monerium link message hash +/// signed by ATTESTOR and bound to this clone's address — it must never validate +/// redeem orders (that would hand Vortex fiat-payout power; see variant doc §3.2). +contract VortexForwarder { + // ---------------------------------------------------------------- constants + + bytes4 private constant EIP1271_MAGIC = 0x1626ba7e; + bytes4 private constant EIP1271_FAIL = 0xffffffff; + uint16 private constant BPS = 10_000; + + string public constant LINK_MESSAGE = "I hereby declare that I am the address owner."; + + // ------------------------------------------------------------- immutables + // Immutables live in the implementation's code and are shared by all clones. + + IERC20 public immutable EURE; + IERC20 public immutable EURC; + IERC20 public immutable USDC; + ISwapRouter02 public immutable ROUTER; + AggregatorV3Interface public immutable ORACLE; // Chainlink EUR/USD + uint8 public immutable ORACLE_DECIMALS; + IVortexForwarderFactory public immutable FACTORY; + address public immutable ATTESTOR; // signs the Monerium link attestation + address public immutable FEE_RECIPIENT; + uint256 public immutable MAX_ORACLE_AGE; // registry P8 + uint16 public immutable SLIPPAGE_BPS; // registry P1 + uint16 public immutable MAX_FEE_BPS; // registry P2 + uint256 public immutable SWEEP_DELAY; // registry P3 + uint256 public immutable TRIGGER_DELAY; // registry P4 + uint24 public immutable POOL_FEE_EURE_EURC; // registry P10 + uint24 public immutable POOL_FEE_EURC_USDC; // registry P10 + + /// @dev EIP-191 personal-message hash and raw keccak of LINK_MESSAGE. Monerium's + /// exact hashing scheme is a G0 spike output (task 4); accepting both is safe + /// because both encode only the fixed link message. + bytes32 public immutable LINK_HASH_191; + + /// @dev Monerium issuer-recovery message hash (registry T1). bytes32(0) = disabled. + /// When enabled, allows Monerium's recovery burn to validate against this + /// contract; payout is constrained by Monerium to the client's own verified + /// bank account, so this grants Vortex no disposal power. + bytes32 public immutable RECOVERY_HASH; + + struct ImmutableConfig { + address eure; + address eurc; + address usdc; + address router; + address oracle; + address attestor; + address feeRecipient; + uint256 maxOracleAge; + uint16 slippageBps; + uint16 maxFeeBps; + uint256 sweepDelay; + uint256 triggerDelay; + uint24 poolFeeEureEurc; + uint24 poolFeeEurcUsdc; + bytes32 recoveryHash; + } + + // ---------------------------------------------------------------- storage + // Per-clone state, set once by the factory in the deployment transaction. + + bool public initialized; + address public destination; // client's payout address (may be a CEX deposit address) + address public fallbackAddress; // client's self-custodied recovery address (mandatory) + uint16 public feeBps; // immutable post-init (registry B1; pilot = 0) + + bool public clientPaused; // set by fallbackAddress only + bool public guardianPaused; // set by guardian only (protective-only; cannot block fallback paths) + + /// @dev R03 marker: when the EURe balance first crossed minSwapAmount with no + /// successful swap since. Start time for TRIGGER_DELAY and SWEEP_DELAY. + uint64 public strandedSince; + + uint256 private _reentrancyGuard; + + // ----------------------------------------------------------------- events + + event Initialized(address destination, address fallbackAddress, uint16 feeBps); + event Poked(uint64 strandedSince); + event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded); + event StrandedEureSwept(address indexed caller, uint256 amount); + event DestinationUpdated(address previous, address current); + event FallbackAddressUpdated(address previous, address current); + event ClientPausedSet(bool paused); + event GuardianPausedSet(bool paused); + event TokenSwept(address indexed token, address indexed to, uint256 amount); + + // ----------------------------------------------------------------- errors + + error AlreadyInitialized(); + error NotFactory(); + error NotFallbackAddress(); + error NotGuardian(); + error NotAuthorizedYet(); + error Paused(); + error ZeroAddress(); + error InvalidConfigAddress(); + error FeeTooHigh(); + error BelowMinimum(); + error StalePrice(); + error InvalidPrice(); + error InsufficientOutput(); + error Overspend(); + error NotStranded(); + error DelayNotElapsed(); + error TransferFailed(); + error Reentrancy(); + + // ------------------------------------------------------------ constructor + + constructor(ImmutableConfig memory cfg) { + EURE = IERC20(cfg.eure); + EURC = IERC20(cfg.eurc); + USDC = IERC20(cfg.usdc); + ROUTER = ISwapRouter02(cfg.router); + ORACLE = AggregatorV3Interface(cfg.oracle); + ORACLE_DECIMALS = AggregatorV3Interface(cfg.oracle).decimals(); + FACTORY = IVortexForwarderFactory(msg.sender); + ATTESTOR = cfg.attestor; + FEE_RECIPIENT = cfg.feeRecipient; + MAX_ORACLE_AGE = cfg.maxOracleAge; + SLIPPAGE_BPS = cfg.slippageBps; + MAX_FEE_BPS = cfg.maxFeeBps; + SWEEP_DELAY = cfg.sweepDelay; + TRIGGER_DELAY = cfg.triggerDelay; + POOL_FEE_EURE_EURC = cfg.poolFeeEureEurc; + POOL_FEE_EURC_USDC = cfg.poolFeeEurcUsdc; + RECOVERY_HASH = cfg.recoveryHash; + + LINK_HASH_191 = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n45", LINK_MESSAGE)); + + // Brick the implementation itself; only clones can be initialized. + initialized = true; + } + + // ------------------------------------------------------------- modifiers + + modifier nonReentrant() { + if (_reentrancyGuard != 0) revert Reentrancy(); + _reentrancyGuard = 1; + _; + _reentrancyGuard = 0; + } + + modifier onlyFallback() { + if (msg.sender != fallbackAddress) revert NotFallbackAddress(); + _; + } + + modifier onlyGuardian() { + if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + _; + } + + // ---------------------------------------------------------- initialization + + /// @notice Called by the factory in the same transaction as clone deployment. + function initialize(address destination_, address fallbackAddress_, uint16 feeBps_) external { + if (msg.sender != address(FACTORY)) revert NotFactory(); + if (initialized) revert AlreadyInitialized(); + _validateConfigAddress(destination_); + _validateConfigAddress(fallbackAddress_); + if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh(); + + initialized = true; + destination = destination_; + fallbackAddress = fallbackAddress_; + feeBps = feeBps_; + emit Initialized(destination_, fallbackAddress_, feeBps_); + } + + // -------------------------------------------------------------- EIP-1271 + + /// @notice Constrained EIP-1271: validates ONLY the fixed Monerium link message + /// (and, if enabled via RECOVERY_HASH, Monerium's recovery message), + /// signed by ATTESTOR over keccak256(chainid, address(this), hash). + /// Binding to chainid + address(this) prevents replay across clones AND + /// across chains (review r1 P2); restricting to ATTESTOR prevents third + /// parties from linking this address to a foreign Monerium profile. + /// Only the EIP-191 hash variant is accepted: G0 sandbox validation + /// (2026-07-17) confirmed Monerium presents the EIP-191 hash, so the + /// raw-keccak fallback was removed to minimize surface. + function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { + bool isLink = hash == LINK_HASH_191; + bool isRecovery = (RECOVERY_HASH != bytes32(0) && hash == RECOVERY_HASH); + if (!isLink && !isRecovery) return EIP1271_FAIL; + if (signature.length != 65) return EIP1271_FAIL; + + bytes32 r; + bytes32 s; + uint8 v; + // solhint-disable-next-line no-inline-assembly + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 0x20)) + v := byte(0, calldataload(add(signature.offset, 0x40))) + } + // Reject malleable signatures (high-s) and invalid v. + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) return EIP1271_FAIL; + if (v != 27 && v != 28) return EIP1271_FAIL; + + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(this), hash)); + address signer = ecrecover(bound, v, r, s); + if (signer == address(0) || signer != ATTESTOR) return EIP1271_FAIL; + return EIP1271_MAGIC; + } + + // ------------------------------------------------------------ stranding marker (R03) + + /// @notice Permissionless. Records when the EURe balance first crossed the swap + /// threshold (start time for TRIGGER_DELAY / SWEEP_DELAY), and clears the + /// marker if the balance dropped back below it. + function poke() external { + // Armed against the IMMUTABLE floor, not the guardian-tunable minSwapAmount: + // otherwise the guardian could raise minSwapAmount above a client's balance and + // a poke() would clear the marker, permanently disabling the un-pausable + // dead-man sweep (review r1, finding F1 — breach of plan invariant §2.3.5). + uint256 balance = EURE.balanceOf(address(this)); + if (balance >= FACTORY.MIN_SWAP_FLOOR()) { + if (strandedSince == 0) { + strandedSince = uint64(block.timestamp); + emit Poked(strandedSince); + } + } else if (strandedSince != 0) { + strandedSince = 0; + emit Poked(0); + } + } + + // ------------------------------------------------------------------ swap + + /// @notice Convert EURe held by this contract to USDC and forward to `destination`. + /// Callable by guardian/keepers any time; by anyone once the stranding + /// marker is older than TRIGGER_DELAY (liveness fallback). + function swapAndForward() external nonReentrant { + if (clientPaused || guardianPaused || FACTORY.globalPaused()) revert Paused(); + + bool privileged = msg.sender == FACTORY.guardian() || FACTORY.isKeeper(msg.sender); + if (!privileged) { + if (strandedSince == 0) revert NotAuthorizedYet(); + if (block.timestamp - strandedSince < TRIGGER_DELAY) revert NotAuthorizedYet(); + } + + uint256 eureBefore = EURE.balanceOf(address(this)); + if (eureBefore < FACTORY.minSwapAmount()) revert BelowMinimum(); + uint256 amountIn = eureBefore; + uint256 cap = FACTORY.perSwapCap(); + if (amountIn > cap) amountIn = cap; + + uint256 minOut = _minOut(amountIn); + uint256 usdcBefore = USDC.balanceOf(address(this)); + + _approve(EURE, address(ROUTER), amountIn); + ROUTER.exactInput( + ISwapRouter02.ExactInputParams({ + path: abi.encodePacked( + address(EURE), POOL_FEE_EURE_EURC, address(EURC), POOL_FEE_EURC_USDC, address(USDC) + ), + recipient: address(this), + amountIn: amountIn, + amountOutMinimum: minOut + }) + ); + _approve(EURE, address(ROUTER), 0); + + uint256 usdcReceived = USDC.balanceOf(address(this)) - usdcBefore; + if (usdcReceived < minOut) revert InsufficientOutput(); + if (eureBefore - EURE.balanceOf(address(this)) > amountIn) revert Overspend(); + + uint256 fee = 0; + if (feeBps > 0) { + fee = (usdcReceived * feeBps) / BPS; + if (fee > 0) _transfer(USDC, FEE_RECIPIENT, fee); + } + + // Full-balance sweep: unsolicited USDC goes to the client's destination too (R09). + uint256 forwarded = USDC.balanceOf(address(this)); + _transfer(USDC, destination, forwarded); + + // Re-arm instead of clearing when a perSwapCap remainder stays behind (review r1 + // P2): otherwise the remainder's dead-man/permissionless timers would silently + // restart from zero only after a fresh poke(). + strandedSince = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() ? uint64(block.timestamp) : 0; + emit SwapExecuted(msg.sender, amountIn, usdcReceived, fee, forwarded); + } + + /// @dev minOut = amountIn * price * (1 - slippage), rescaled EURe(18) -> USDC(6). + /// Scale denominator: 10^(18 + oracleDecimals - 6). Floor rounding: conservative + /// direction; error < 1 unit of USDC. Assumes USDC/USD = 1 within SLIPPAGE_BPS + /// (documented assumption A4; PRD v2 §7.3). + function _minOut(uint256 amountIn) internal view returns (uint256) { + (, int256 answer,, uint256 updatedAt,) = ORACLE.latestRoundData(); + if (answer <= 0) revert InvalidPrice(); + if (updatedAt == 0 || block.timestamp - updatedAt > MAX_ORACLE_AGE) revert StalePrice(); + return (amountIn * uint256(answer) * (BPS - SLIPPAGE_BPS)) / (10 ** (12 + uint256(ORACLE_DECIMALS))) / BPS; + } + + // -------------------------------------------------------------- recovery + + /// @notice Permissionless dead-man sweep: after SWEEP_DELAY of stranding, anyone may + /// move the full EURe balance to the client's fallbackAddress. Deliberately + /// NOT gated on pause flags: recovery must work during incidents. Never + /// targets `destination` (CEX rule — variant doc §6). + function sweepStrandedEure() external nonReentrant { + if (strandedSince == 0) revert NotStranded(); + if (block.timestamp - strandedSince < SWEEP_DELAY) revert DelayNotElapsed(); + uint256 balance = EURE.balanceOf(address(this)); + _transfer(EURE, fallbackAddress, balance); + strandedSince = 0; + emit StrandedEureSwept(msg.sender, balance); + } + + // ------------------------------------------------------- client (fallback) authority + + function setDestination(address destination_) external onlyFallback { + _validateConfigAddress(destination_); + emit DestinationUpdated(destination, destination_); + destination = destination_; + } + + function setFallbackAddress(address fallbackAddress_) external onlyFallback { + _validateConfigAddress(fallbackAddress_); + emit FallbackAddressUpdated(fallbackAddress, fallbackAddress_); + fallbackAddress = fallbackAddress_; + } + + function setClientPaused(bool paused) external onlyFallback { + clientPaused = paused; + emit ClientPausedSet(paused); + } + + /// @notice Client exit hatch: move any token (incl. EURe/USDC/unsolicited) anywhere. + /// Works while paused — guardian pause must never trap client funds. + function sweep(address token, address to) external onlyFallback nonReentrant { + if (to == address(0)) revert ZeroAddress(); + uint256 balance = IERC20(token).balanceOf(address(this)); + _transfer(IERC20(token), to, balance); + emit TokenSwept(token, to, balance); + } + + // ----------------------------------------------------------- guardian authority + + /// @notice Protective-only: blocks swaps (compliance holds, dormancy gate — R05). + /// Cannot move funds, change config, or block fallback paths. + function setGuardianPaused(bool paused) external onlyGuardian { + guardianPaused = paused; + emit GuardianPausedSet(paused); + } + + // ---------------------------------------------------------------- helpers + + function _validateConfigAddress(address account) internal view { + if (account == address(0)) revert ZeroAddress(); + if ( + account == address(EURE) || account == address(EURC) || account == address(USDC) + || account == address(ROUTER) || account == address(this) + ) revert InvalidConfigAddress(); + } + + function _transfer(IERC20 token, address to, uint256 amount) internal { + if (amount == 0) return; + (bool success, bytes memory data) = address(token).call(abi.encodeCall(IERC20.transfer, (to, amount))); + if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } + + function _approve(IERC20 token, address spender, uint256 amount) internal { + (bool success, bytes memory data) = address(token).call(abi.encodeCall(IERC20.approve, (spender, amount))); + if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } +} diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol new file mode 100644 index 000000000..0e12fde06 --- /dev/null +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {VortexForwarder} from "./VortexForwarder.sol"; + +/// @title VortexForwarderFactory +/// @notice Deploys per-client VortexForwarder clones (EIP-1167, CREATE2) and holds the +/// guardian role plus bounded operational parameters shared by all clones +/// (implementation plan §2.2; parameter bounds are registry P6/P7). +contract VortexForwarderFactory { + address public immutable implementation; + + /// @dev Immutable bounds for the operational parameters (R10): the guardian can + /// tune values only inside [floor, ceiling]; the bounds themselves never move. + uint256 public immutable MIN_SWAP_FLOOR; + uint256 public immutable CAP_CEILING; + + address public guardian; + address public pendingGuardian; + mapping(address => bool) public isKeeper; + bool public globalPaused; + + uint256 public minSwapAmount; // registry P6 + uint256 public perSwapCap; // registry P7 + + mapping(address => bool) public isForwarder; + + event ForwarderDeployed( + address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt + ); + event KeeperSet(address indexed keeper, bool enabled); + event GlobalPausedSet(bool paused); + event MinSwapAmountSet(uint256 value); + event PerSwapCapSet(uint256 value); + event GuardianTransferStarted(address indexed current, address indexed pending); + event GuardianTransferred(address indexed previous, address indexed current); + + error NotGuardian(); + error NotPendingGuardian(); + error OutOfBounds(); + error CloneFailed(); + + modifier onlyGuardian() { + if (msg.sender != guardian) revert NotGuardian(); + _; + } + + constructor( + VortexForwarder.ImmutableConfig memory cfg, + uint256 minSwapFloor, + uint256 capCeiling, + uint256 initialMinSwapAmount, + uint256 initialPerSwapCap + ) { + guardian = msg.sender; + implementation = address(new VortexForwarder(cfg)); + MIN_SWAP_FLOOR = minSwapFloor; + CAP_CEILING = capCeiling; + _setMinSwapAmount(initialMinSwapAmount); + _setPerSwapCap(initialPerSwapCap); + } + + // ------------------------------------------------------------- deployment + + /// @notice Deploy and initialize a client forwarder in one transaction. The clone + /// address is deterministic (CREATE2) so it can be communicated/linked + /// reliably; predict it with `predictAddress` before deploying. + function deployForwarder(address destination, address fallbackAddress, uint16 feeBps, bytes32 salt) + external + onlyGuardian + returns (address forwarder) + { + forwarder = _cloneDeterministic(implementation, salt); + VortexForwarder(forwarder).initialize(destination, fallbackAddress, feeBps); + isForwarder[forwarder] = true; + emit ForwarderDeployed(forwarder, destination, fallbackAddress, feeBps, salt); + } + + function predictAddress(bytes32 salt) external view returns (address) { + bytes32 initCodeHash = keccak256(_cloneInitCode(implementation)); + return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash))))); + } + + // ------------------------------------------------------------- governance + + function setKeeper(address keeper, bool enabled) external onlyGuardian { + isKeeper[keeper] = enabled; + emit KeeperSet(keeper, enabled); + } + + function setGlobalPaused(bool paused) external onlyGuardian { + globalPaused = paused; + emit GlobalPausedSet(paused); + } + + function setMinSwapAmount(uint256 value) external onlyGuardian { + _setMinSwapAmount(value); + } + + function setPerSwapCap(uint256 value) external onlyGuardian { + _setPerSwapCap(value); + } + + /// @dev Two-step transfer: guardian is load-bearing for every clone's pause and + /// keeper gating, so a fat-fingered transfer must not be possible. + function transferGuardian(address newGuardian) external onlyGuardian { + pendingGuardian = newGuardian; + emit GuardianTransferStarted(guardian, newGuardian); + } + + function acceptGuardian() external { + if (msg.sender != pendingGuardian) revert NotPendingGuardian(); + emit GuardianTransferred(guardian, msg.sender); + guardian = msg.sender; + pendingGuardian = address(0); + } + + // ---------------------------------------------------------------- helpers + + function _setMinSwapAmount(uint256 value) internal { + if (value < MIN_SWAP_FLOOR || value > perSwapCap && perSwapCap != 0) revert OutOfBounds(); + minSwapAmount = value; + emit MinSwapAmountSet(value); + } + + function _setPerSwapCap(uint256 value) internal { + if (value > CAP_CEILING || value < minSwapAmount) revert OutOfBounds(); + perSwapCap = value; + emit PerSwapCapSet(value); + } + + /// @dev Standard EIP-1167 minimal proxy init code for `target`. + function _cloneInitCode(address target) internal pure returns (bytes memory) { + return abi.encodePacked( + hex"3d602d80600a3d3981f3363d3d373d3d3d363d73", target, hex"5af43d82803e903d91602b57fd5bf3" + ); + } + + function _cloneDeterministic(address target, bytes32 salt) internal returns (address instance) { + bytes memory initCode = _cloneInitCode(target); + // solhint-disable-next-line no-inline-assembly + assembly { + instance := create2(0, add(initCode, 0x20), mload(initCode), salt) + } + if (instance == address(0)) revert CloneFailed(); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol new file mode 100644 index 000000000..c4a7c2918 --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; + +interface IUniswapV3Factory { + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address); +} + +interface IERC20Meta { + function balanceOf(address) external view returns (uint256); + function decimals() external view returns (uint8); +} + +/// Mainnet fork tests against the real tokens, pools, router, and oracle. +/// Skipped when ETH_RPC_URL is not set. Addresses below are build-time pins — +/// re-verify per registry P10/G0 before any deployment. +contract VortexForwarderForkTest is Test { + // EURe V2 (Monerium, verified via CoinGecko + Etherscan 2026-07-10). V1 is deprecated. + address constant EURE_V2 = 0x39b8B6385416f4cA36a20319F70D28621895279D; + address constant EURE_V1_DEPRECATED = 0x3231Cb76718CDeF2155FC47b5286d82e6eDA273f; + address constant EURC = 0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant SWAP_ROUTER_02 = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; + address constant UNIV3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + // Chainlink EUR/USD proxy — verify against data.chain.link before deploy (registry P8). + address constant CHAINLINK_EUR_USD = 0xb49f677943BC038e9857d61E7d053CaA2C1734C1; + + VortexForwarderFactory factory; + VortexForwarder fwd; + + address attestor = vm.addr(0xA11CE); + address destination = makeAddr("destination"); + address fallbackAddr = makeAddr("fallbackAddr"); + address keeper = makeAddr("keeper"); + + bool forked; + + function setUp() public { + string memory rpc = vm.envOr("ETH_RPC_URL", string("")); + if (bytes(rpc).length == 0) return; // tests will self-skip + vm.createSelectFork(rpc); + forked = true; + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: EURE_V2, + eurc: EURC, + usdc: USDC, + router: SWAP_ROUTER_02, + oracle: CHAINLINK_EUR_USD, + attestor: attestor, + feeRecipient: makeAddr("feeRecipient"), + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: 60 days, + triggerDelay: 24 hours, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + } + + modifier onlyForked() { + vm.skip(!forked); + _; + } + + function test_fork_oracleIsEurUsdWithPlausiblePrice() public onlyForked { + assertEq(fwd.ORACLE_DECIMALS(), 8, "EUR/USD feed should have 8 decimals"); + (, int256 answer,, uint256 updatedAt,) = fwd.ORACLE().latestRoundData(); + // Plausibility band: a wrong feed address fails loudly here. + assertGt(answer, 0.8e8, "EUR/USD implausibly low"); + assertLt(answer, 1.6e8, "EUR/USD implausibly high"); + assertGt(updatedAt, 0); + } + + function test_fork_pinnedPathUsesV2AndPoolsExist() public onlyForked { + assertEq(address(fwd.EURE()), EURE_V2); + assertTrue(address(fwd.EURE()) != EURE_V1_DEPRECATED, "route must never touch deprecated V1 EURe"); + + // Both hops of the pinned path must exist on-chain with the pinned fee tiers. + address hop1 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURE_V2, EURC, fwd.POOL_FEE_EURE_EURC()); + address hop2 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURC, USDC, fwd.POOL_FEE_EURC_USDC()); + assertTrue(hop1 != address(0), "EURe/EURC pool missing at pinned fee tier"); + assertTrue(hop2 != address(0), "EURC/USDC pool missing at pinned fee tier"); + // The V2 pool must actually hold V2 tokens (stale-pool trap check). + assertGt(IERC20Meta(EURE_V2).balanceOf(hop1), 0, "pinned hop1 pool holds no V2 EURe"); + } + + function test_fork_swapAndForward_executesWithinOracleBounds() public onlyForked { + uint256 amountIn = 1_000e18; + deal(EURE_V2, address(fwd), amountIn); // stdStorage balance override + + (, int256 answer,,,) = fwd.ORACLE().latestRoundData(); + uint256 fair = (amountIn * uint256(answer)) / 1e20; // 6-dec USDC at oracle rate + + vm.prank(keeper); + fwd.swapAndForward(); + + uint256 received = IERC20Meta(USDC).balanceOf(destination); + assertGe(received, (fair * 9_900) / 10_000, "below oracle-bounded minOut"); + assertLe(received, (fair * 10_300) / 10_000, "implausibly above oracle rate"); + assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 0, "EURe left behind"); + assertEq(IERC20Meta(USDC).balanceOf(address(fwd)), 0, "USDC left behind"); + } + + function test_fork_perSwapCapLeavesRemainder() public onlyForked { + deal(EURE_V2, address(fwd), 12_000e18); // cap is 10k + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 2_000e18); + assertGt(IERC20Meta(USDC).balanceOf(destination), 0); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol new file mode 100644 index 000000000..91dbab084 --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; +import {MockERC20, MockOracle, MockRouter} from "./VortexForwarder.t.sol"; + +/// Randomized action handler. Ghost variables track every token unit entering the +/// system so the invariants below can assert exit-path exhaustiveness (plan §2.3.1): +/// EURe may sit in the forwarder, be consumed by the router, or reach the client's +/// fallback; USDC may only reach destination + feeRecipient; nothing else, ever. +contract ForwarderHandler is Test { + VortexForwarderFactory public factory; + VortexForwarder public fwd; + MockERC20 public eure; + MockERC20 public usdc; + MockERC20 public eurc; + MockOracle public oracle; + MockRouter public router; + + address public destination = makeAddr("destination"); + address public fallbackAddr = makeAddr("fallbackAddr"); + address public keeper = makeAddr("keeper"); + address public rando = makeAddr("rando"); + address public feeRecipient = makeAddr("feeRecipient"); + + uint256 public ghostEureMinted; + uint256 public ghostUsdcPaidByRouter; + uint256 public fallbackSweepFailures; + uint16 public immutable INITIAL_FEE_BPS = 50; + + constructor() { + eure = new MockERC20("EURe", 18); + eurc = new MockERC20("EURC", 6); + usdc = new MockERC20("USDC", 6); + oracle = new MockOracle(); + router = new MockRouter(eure, usdc); + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: vm.addr(0xA11CE), + feeRecipient: feeRecipient, + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: 60 days, + triggerDelay: 24 hours, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, INITIAL_FEE_BPS, bytes32(uint256(1)))); + } + + // ------------------------------------------------------------- actions + + function fund(uint96 raw) external { + uint256 amount = bound(uint256(raw), 0, 20_000e18); + eure.mint(address(fwd), amount); + ghostEureMinted += amount; + } + + function doPoke() external { + fwd.poke(); + } + + function warp(uint32 raw) external { + vm.warp(block.timestamp + bound(uint256(raw), 1, 90 days)); + } + + /// Router pays a randomized amount around the fair oracle value; underpayment + /// exercises the minOut revert path, overpayment the happy path. + function keeperSwap(uint96 raw) external { + _swapAs(keeper, raw); + } + + function randoSwap(uint96 raw) external { + _swapAs(rando, raw); + } + + function _swapAs(address caller, uint96 raw) internal { + oracle.set(1.14e8, block.timestamp); + uint256 balance = eure.balanceOf(address(fwd)); + uint256 amountIn = balance > 10_000e18 ? 10_000e18 : balance; + uint256 fair = (amountIn * 1.14e8) / 1e20; + // 95%..105% of fair value; below 99% the swap must revert on minOut. + uint256 payout = bound(uint256(raw), (fair * 95) / 100, (fair * 105) / 100); + router.setNextOut(payout); + + uint256 routerUsdcBefore = usdc.totalMinted(); + vm.prank(caller); + try fwd.swapAndForward() { + ghostUsdcPaidByRouter += usdc.totalMinted() - routerUsdcBefore; + } catch {} + } + + function sweepStranded() external { + try fwd.sweepStrandedEure() {} catch {} + } + + function guardianPause(bool paused) external { + fwd.setGuardianPaused(paused); // handler deployed the factory -> handler is guardian + } + + 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 % 4 == 0) try fwd.setDestination(rando) {} catch {} + if (selector % 4 == 1) try fwd.setGuardianPaused(true) {} catch {} + if (selector % 4 == 2) try fwd.setFallbackAddress(rando) {} catch {} + if (selector % 4 == 3) try fwd.sweep(address(eure), rando) {} catch {} + vm.stopPrank(); + } +} + +contract VortexForwarderInvariantTest is Test { + ForwarderHandler handler; + + function setUp() public { + handler = new ForwarderHandler(); + targetContract(address(handler)); + } + + /// Exit-path exhaustiveness for EURe: every unit ever minted into the forwarder is + /// either still there, consumed by the router (swap), or at the client's fallback. + function invariant_eureConservation() public view { + uint256 accounted = handler.eure().balanceOf(address(handler.fwd())) + + handler.eure().balanceOf(address(handler.router())) + handler.eure().balanceOf(handler.fallbackAddr()); + assertEq(accounted, handler.ghostEureMinted(), "EURe leaked to an unexpected address"); + } + + /// Exit-path exhaustiveness for USDC: everything the router ever paid ends up + /// split between destination and feeRecipient; the forwarder retains nothing. + function invariant_usdcOnlyReachesDestinationAndFee() public view { + uint256 accounted = + handler.usdc().balanceOf(handler.destination()) + handler.usdc().balanceOf(handler.feeRecipient()); + assertEq(accounted, handler.ghostUsdcPaidByRouter(), "USDC leaked to an unexpected address"); + assertEq(handler.usdc().balanceOf(address(handler.fwd())), 0, "forwarder retained USDC"); + } + + /// feeBps is immutable post-init; rando privileged-call attempts must never mutate config. + function invariant_configIntegrity() public view { + assertEq(handler.fwd().feeBps(), handler.INITIAL_FEE_BPS()); + assertEq(handler.fwd().destination(), handler.destination()); + assertEq(handler.fwd().fallbackAddress(), handler.fallbackAddr()); + } + + /// Guardian/global pause must never block the client's exit hatch. + function invariant_fallbackSweepNeverBlocked() public view { + assertEq(handler.fallbackSweepFailures(), 0, "client exit hatch was blocked"); + } + + /// The stranding marker never points into the future. + function invariant_strandedSinceNotInFuture() public view { + assertLe(handler.fwd().strandedSince(), block.timestamp); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol new file mode 100644 index 000000000..43728a7b7 --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -0,0 +1,526 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder, IERC20, ISwapRouter02} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; + +contract MockERC20 { + string public name; + uint8 public decimals; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(string memory name_, uint8 decimals_) { + name = name_; + decimals = decimals_; + } + + uint256 public totalMinted; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalMinted += amount; + } + + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } +} + +contract MockOracle { + int256 public answer = 1.14e8; // EUR/USD + uint256 public updatedAt = block.timestamp; + uint8 public constant decimals = 8; + + function set(int256 answer_, uint256 updatedAt_) external { + answer = answer_; + updatedAt = updatedAt_; + } + + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { + return (1, answer, updatedAt, updatedAt, 1); + } +} + +contract MockRouter { + MockERC20 public immutable eure; + MockERC20 public immutable usdc; + uint256 public nextOut; + + constructor(MockERC20 eure_, MockERC20 usdc_) { + eure = eure_; + usdc = usdc_; + } + + function setNextOut(uint256 v) external { + nextOut = v; + } + + function exactInput(ISwapRouter02.ExactInputParams calldata params) external payable returns (uint256) { + eure.transferFrom(msg.sender, address(this), params.amountIn); + require(nextOut >= params.amountOutMinimum, "Too little received"); + usdc.mint(params.recipient, nextOut); + return nextOut; + } +} + +/// Malicious router that tries to re-enter swapAndForward during the swap. +contract MockReentrantRouter { + function exactInput(ISwapRouter02.ExactInputParams calldata) external payable returns (uint256) { + VortexForwarder(msg.sender).swapAndForward(); // must revert via reentrancy guard + return 0; + } +} + +contract VortexForwarderTest is Test { + MockERC20 eure; + MockERC20 eurc; + MockERC20 usdc; + MockOracle oracle; + MockRouter router; + VortexForwarderFactory factory; + VortexForwarder fwd; + + uint256 attestorPk = 0xA11CE; + address attestor; + address feeRecipient = makeAddr("feeRecipient"); + address destination = makeAddr("destination"); + address fallbackAddr = makeAddr("fallbackAddr"); + address keeper = makeAddr("keeper"); + address rando = makeAddr("rando"); + + uint256 constant TRIGGER_DELAY = 24 hours; + uint256 constant SWEEP_DELAY = 60 days; + + function setUp() public { + attestor = vm.addr(attestorPk); + eure = new MockERC20("EURe", 18); + eurc = new MockERC20("EURC", 6); + usdc = new MockERC20("USDC", 6); + oracle = new MockOracle(); + router = new MockRouter(eure, usdc); + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, // MIN_SWAP_FLOOR + 50_000e18, // CAP_CEILING + 25e18, // minSwapAmount + 10_000e18 // perSwapCap + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + } + + // ---------------------------------------------------------------- helpers + + function _attest(address forwarder, bytes32 hash) internal view returns (bytes memory) { + bytes32 bound = keccak256(abi.encodePacked(block.chainid, forwarder, hash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); + return abi.encodePacked(r, s, v); + } + + function _fund(uint256 amount) internal { + eure.mint(address(fwd), amount); + } + + // ---------------------------------------------------------------- EIP-1271 + + function test_linkSignature_valid_eip191Only() public view { + bytes32 h191 = fwd.LINK_HASH_191(); + assertEq(fwd.isValidSignature(h191, _attest(address(fwd), h191)), bytes4(0x1626ba7e)); + // Raw-keccak variant was removed after G0 sandbox validation confirmed Monerium + // presents the EIP-191 hash; it must now be rejected even with a valid attestor sig. + bytes32 hRaw = keccak256(bytes("I hereby declare that I am the address owner.")); + assertEq(fwd.isValidSignature(hRaw, _attest(address(fwd), hRaw)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsCrossChainReplay() public { + bytes32 h = fwd.LINK_HASH_191(); + bytes memory sig = _attest(address(fwd), h); // bound to current chainid + vm.chainId(999); + assertEq(fwd.isValidSignature(h, sig), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsMalleatedAndMalformed() public view { + bytes32 h = fwd.LINK_HASH_191(); + bytes memory good = _attest(address(fwd), h); + // Malleate: s' = n - s, v' = flipped — same ECDSA validity, must be rejected. + (bytes32 r, bytes32 s, uint8 v) = (bytes32(0), bytes32(0), 0); + assembly { + r := mload(add(good, 0x20)) + s := mload(add(good, 0x40)) + v := byte(0, mload(add(good, 0x60))) + } + uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + bytes memory malleated = abi.encodePacked(r, bytes32(n - uint256(s)), v == 27 ? uint8(28) : uint8(27)); + assertEq(fwd.isValidSignature(h, malleated), bytes4(0xffffffff)); + // Wrong lengths. + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s)), bytes4(0xffffffff)); + assertEq(fwd.isValidSignature(h, abi.encodePacked(good, uint8(1))), bytes4(0xffffffff)); + // Bad v. + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s, uint8(29))), bytes4(0xffffffff)); + } + + function test_recoveryHash_enabledBranch() public { + bytes32 recoveryHash = keccak256("monerium-recovery-message-placeholder"); + VortexForwarderFactory f2 = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: recoveryHash + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(8)))); + // Recovery hash validates with attestor binding; link still validates; others fail. + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd2), recoveryHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); + assertEq(fwd2.isValidSignature(recoveryHash, abi.encodePacked(r, s, v)), bytes4(0x1626ba7e)); + bytes32 h191 = fwd2.LINK_HASH_191(); + bytes32 bound191 = keccak256(abi.encodePacked(block.chainid, address(fwd2), h191)); + (v, r, s) = vm.sign(attestorPk, bound191); + assertEq(fwd2.isValidSignature(h191, abi.encodePacked(r, s, v)), bytes4(0x1626ba7e)); + bytes32 evil = keccak256("anything else"); + bytes32 boundEvil = keccak256(abi.encodePacked(block.chainid, address(fwd2), evil)); + (v, r, s) = vm.sign(attestorPk, boundEvil); + assertEq(fwd2.isValidSignature(evil, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function test_linkHash191_matchesEip191OfFixedMessage() public view { + bytes memory msg_ = bytes("I hereby declare that I am the address owner."); + assertEq(msg_.length, 45); + assertEq(fwd.LINK_HASH_191(), keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n45", msg_))); + } + + function test_linkSignature_rejectsForeignHash() public view { + bytes32 evil = keccak256("Send EUR 100000 to DE00ATTACKER at 2026-07-17T00:00Z"); + assertEq(fwd.isValidSignature(evil, _attest(address(fwd), evil)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsWrongSigner() public { + bytes32 h = fwd.LINK_HASH_191(); + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd), h)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xBAD, bound); + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsCrossCloneReplay() public { + VortexForwarder other = + VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(2)))); + bytes32 h = fwd.LINK_HASH_191(); + // Signature bound to `fwd` must not validate on `other`. + assertEq(other.isValidSignature(h, _attest(address(fwd), h)), bytes4(0xffffffff)); + } + + // ---------------------------------------------------------------- init + + function test_initialize_onlyFactory_andOnce() public { + vm.expectRevert(VortexForwarder.NotFactory.selector); + fwd.initialize(rando, rando, 0); + + vm.prank(address(factory)); + vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); + fwd.initialize(rando, rando, 0); + } + + function test_implementation_isBricked() public { + VortexForwarder impl = VortexForwarder(factory.implementation()); + vm.prank(address(factory)); + vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); + impl.initialize(rando, rando, 0); + } + + // ---------------------------------------------------------------- swap + + function test_swapAndForward_happyPath_forwardsToDestination() public { + _fund(1_000e18); + // minOut = 1000 * 1.14 * 0.99 = 1128.6 USDC + router.setNextOut(1_130e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6); + assertEq(eure.balanceOf(address(fwd)), 0); + assertEq(eure.allowance(address(fwd), address(router)), 0); + } + + function test_swapAndForward_enforcesOracleMinOut() public { + _fund(1_000e18); + router.setNextOut(1_100e6); // below 1128.6 -> router-side minOut check fires + vm.prank(keeper); + vm.expectRevert("Too little received"); + fwd.swapAndForward(); + } + + function test_swapAndForward_revertsOnStaleOracle() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + oracle.set(1.14e8, block.timestamp); + skip(27 hours); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.StalePrice.selector); + fwd.swapAndForward(); + } + + function test_swapAndForward_publicOnlyAfterTriggerDelay() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.swapAndForward(); + + fwd.poke(); + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.swapAndForward(); + + skip(TRIGGER_DELAY + 1); + oracle.set(1.14e8, block.timestamp); + vm.prank(rando); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6); + } + + function test_swapAndForward_revertsOnZeroOrNegativePrice() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + oracle.set(0, block.timestamp); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InvalidPrice.selector); + fwd.swapAndForward(); + oracle.set(-1, block.timestamp); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InvalidPrice.selector); + fwd.swapAndForward(); + } + + /// Review r1 P2: a perSwapCap remainder must keep its stranding timers armed — + /// the swap re-arms the marker rather than clearing it when balance stays >= floor. + function test_swapAndForward_reArmsMarkerForCapRemainder() public { + _fund(15_000e18); // cap is 10k + fwd.poke(); + assertGt(fwd.strandedSince(), 0); + router.setNextOut(11_290e6); + skip(1 hours); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(eure.balanceOf(address(fwd)), 5_000e18); + assertEq(fwd.strandedSince(), block.timestamp, "remainder must stay armed (fresh timestamp)"); + } + + function test_swapAndForward_respectsPerSwapCap() public { + _fund(15_000e18); // cap is 10k + // minOut for 10k at 1.14*0.99 = 11286 USDC + router.setNextOut(11_290e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits next execution + } + + function test_swapAndForward_feeSkim() public { + VortexForwarder feeFwd = + VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 50, bytes32(uint256(3)))); + eure.mint(address(feeFwd), 1_000e18); + router.setNextOut(1_130e6); + vm.prank(keeper); + feeFwd.swapAndForward(); + uint256 fee = (1_130e6 * 50) / 10_000; + assertEq(usdc.balanceOf(feeRecipient), fee); + assertEq(usdc.balanceOf(destination), 1_130e6 - fee); + } + + function test_swapAndForward_pausedByGuardianOrClientOrGlobal() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + + fwd.setGuardianPaused(true); // test contract is factory guardian + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + fwd.setGuardianPaused(false); + + vm.prank(fallbackAddr); + fwd.setClientPaused(true); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + vm.prank(fallbackAddr); + fwd.setClientPaused(false); + + factory.setGlobalPaused(true); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + } + + function test_unsolicitedUsdc_forwardedWithNextSwap() public { + usdc.mint(address(fwd), 500e6); // unsolicited direct transfer (R09) + _fund(1_000e18); + router.setNextOut(1_130e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6 + 500e6); + } + + function test_reentrantRouter_blockedByGuard() public { + MockReentrantRouter evil = new MockReentrantRouter(); + VortexForwarderFactory f2 = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(evil), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + f2.setKeeper(keeper, true); + VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(7)))); + eure.mint(address(fwd2), 1_000e18); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Reentrancy.selector); + fwd2.swapAndForward(); + } + + // ---------------------------------------------------------------- recovery + + function test_sweepStrandedEure_afterDelay_toFallbackOnly() public { + _fund(500e18); + fwd.poke(); + + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.sweepStrandedEure(); + + skip(SWEEP_DELAY + 1); + vm.prank(rando); // permissionless + fwd.sweepStrandedEure(); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + assertEq(fwd.strandedSince(), 0); + } + + /// Review r1 F1 regression: raising the tunable minSwapAmount above a stranded + /// balance must NOT let a poke() clear the marker — the dead-man sweep is armed + /// against the immutable MIN_SWAP_FLOOR and must survive any guardian action. + function test_guardianCannotDisarmDeadManSweep_byRaisingMinSwap() public { + _fund(500e18); + fwd.poke(); + assertGt(fwd.strandedSince(), 0); + + factory.setMinSwapAmount(1_000e18); // guardian raises threshold above balance + fwd.poke(); // anyone can poke; marker must survive + assertGt(fwd.strandedSince(), 0, "guardian disarmed the dead-man sweep"); + + skip(SWEEP_DELAY + 1); + fwd.sweepStrandedEure(); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + } + + function test_fallbackSweep_worksWhilePaused() public { + _fund(500e18); + fwd.setGuardianPaused(true); + vm.prank(fallbackAddr); + fwd.sweep(address(eure), fallbackAddr); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + } + + function test_fallbackAuthority_gated() public { + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotFallbackAddress.selector); + fwd.setDestination(rando); + + address newDest = makeAddr("newDest"); + vm.prank(fallbackAddr); + fwd.setDestination(newDest); + assertEq(fwd.destination(), newDest); + } + + function test_guardianPause_gated() public { + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotGuardian.selector); + fwd.setGuardianPaused(true); + } + + // ---------------------------------------------------------------- factory + + function test_predictAddress_matchesDeployment() public { + bytes32 salt = bytes32(uint256(42)); + address predicted = factory.predictAddress(salt); + address deployed = factory.deployForwarder(destination, fallbackAddr, 0, salt); + assertEq(predicted, deployed); + } + + function test_factory_paramBounds() public { + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setPerSwapCap(60_000e18); // above ceiling + + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setMinSwapAmount(0.5e18); // below floor + + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setMinSwapAmount(20_000e18); // above current cap + } + + function test_feeBps_cappedAtMax() public { + vm.expectRevert(VortexForwarder.FeeTooHigh.selector); + factory.deployForwarder(destination, fallbackAddr, 101, bytes32(uint256(9))); + } +} diff --git a/docs/prd/monerium-b2b-code-review-r1.md b/docs/prd/monerium-b2b-code-review-r1.md new file mode 100644 index 000000000..96719957f --- /dev/null +++ b/docs/prd/monerium-b2b-code-review-r1.md @@ -0,0 +1,174 @@ +# Monerium B2B Forwarder — Adversarial Code Review R1 + +**Reviewer:** adversarial security review (automated) +**Date:** 2026-07-17 +**Scope:** `contracts/monerium-forwarder/` (VortexForwarder + Factory + tests) against the B2B variant spec, implementation plan (invariants §2.3, dispositions §5), and deferred-decisions registry. +**Build/test:** `forge build` clean; `forge test --no-match-contract Fork` = 28 pass / 0 fail. Two PoC tests written during review (guardian stranding-reset, permissionless min-out execution) both passed, then removed. + +## Verdict + +The core non-custody property **holds on-chain**: there is no path for Vortex (attestor/guardian/keeper/deployer) to redirect converted USDC away from the client's `destination` or to a Vortex address, and `isValidSignature` cannot validate a Monerium redeem order. **No P0 found.** The strongest issue is a P1 liveness/griefing escalation: the guardian can indefinitely defeat the permissionless recovery backstop that the design deliberately kept guardian-proof. Several P2 notes and concrete test gaps follow. + +## Findings table + +| # | Sev | Title | Location | +|---|-----|-------|----------| +| F1 | **P1** | Guardian can indefinitely freeze the permissionless recovery paths via `minSwapAmount` raise + `poke()` (violates invariant §2.3.5; defeats un-pausable dead-man sweep) | `VortexForwarder.sol:265-276`, `293`, `328`; `VortexForwarderFactory.sol:96-98,120-124` | +| F2 | P2 | No `chainid`/domain separator in the EIP-1271 bound → cross-chain replay of the link attestation if the forwarder is ever deployed at the same address on another chain (EURe is multichain) | `VortexForwarder.sol:254` | +| F3 | P2 | Oracle staleness check omits round-completeness (`answeredInRound`/`roundId`) | `VortexForwarder.sol:337-339` | +| F4 | P2 | Permissionless `swapAndForward` is MEV-exposed (no private orderflow); executes at up to `SLIPPAGE_BPS` worse, sandwich-extractable | `VortexForwarder.sol:283-311` | +| F5 | P2 | Spec/code drift: variant §3.3 shows `abi.encode`, code uses `abi.encodePacked`; attestor signs the raw `bound` digest. Off-chain signer must match exactly or links silently fail | `VortexForwarder.sol:254` vs variant §3.3 | +| F6 | P2 | `swapAndForward` clears `strandedSince` even when a `perSwapCap` remainder ≥ `minSwapAmount` stays; permissionless path must re-poke + re-wait `TRIGGER_DELAY` per cap-chunk | `VortexForwarder.sol:328` | +| F7 | P2 | `RECOVERY_HASH` mechanism (disabled) only preserves non-custody **iff** Monerium's recovery message is parameterless — a hard constraint on how registry T1 may be resolved | `VortexForwarder.sol:237` | +| F8 | P2 | EURe compliance-transfer-revert behaviour unmodeled/undocumented: if Monerium freezes the forwarder's EURe, both `sweepStrandedEure` and client `sweep` revert (funds stuck) | `VortexForwarder.sol:349-356,379-384` | +| F9 | P2 (nit) | `_setMinSwapAmount` relies on unparenthesised `&&`/`||` precedence | `VortexForwarderFactory.sol:121` | +| F10 | P2 (nit) | Invariant §2.3.3 says "at most the two whitelisted hashes"; code accepts up to three hash constants (191, RAW, RECOVERY) | `VortexForwarder.sol:236-237` vs plan §2.3.3 | +| — | — | Test-suite gaps (enumerated in §"Test gaps") | — | + +--- + +## F1 (P1) — Guardian defeats the permissionless recovery backstop + +**Verified (PoC passed).** + +The dead-man sweep (`sweepStrandedEure`) and the permissionless `swapAndForward` are the design's guardian-proof liveness backstops. `sweepStrandedEure` is *deliberately not pause-gated* (`VortexForwarder.sol:345-347` comment: "recovery must work during incidents"), and implementation-plan invariant §2.3.5 states `strandedSince` is "monotonic per stranding episode; reset only by swap success/balance drop." Both guarantees are bypassable by the guardian. + +`poke()` (`:265-276`) arms/clears the marker against the **mutable, global** `FACTORY.minSwapAmount()`: + +```solidity +if (balance >= FACTORY.minSwapAmount()) { if (strandedSince == 0) strandedSince = ...; } +else if (strandedSince != 0) { strandedSince = 0; ... } // "balance drop" branch +``` + +The guardian can synthesise the "balance drop" by **raising the threshold above an existing balance** (`setMinSwapAmount`, `Factory.sol:96`, bounded only by `[MIN_SWAP_FLOOR, perSwapCap]` — floor is 1e18, so any real balance can be undercut). Sequence: + +1. Client has 100 EURe stranded; `strandedSince` armed at t0. Day 59 of the 60-day dead-man window. +2. Guardian `setMinSwapAmount(200e18)` (in bounds). +3. Anyone `poke()` → `balance(100) < min(200)` → `strandedSince = 0`. The 59 days are erased. +4. While the guardian holds `min > balance`, `poke()` can never re-arm, so `sweepStrandedEure` reverts `NotStranded` **forever**, and `swapAndForward` reverts `BelowMinimum` (`:293`) — no conversion either. + +Net: the guardian unilaterally re-creates the exact "stranded forever, no permissionless rescue" terminal state the dead-man sweep was built to eliminate. This is **strictly more power than pause** (pause leaves `sweepStrandedEure` callable). It is a liveness/griefing escalation, not theft — the client's own `fallbackAddress` can still `sweep()` — but it voids a stated security property for any client relying on the zero-touch/permissionless path, and because `minSwapAmount` is **global**, one raise freezes every client below the threshold at once. + +**Fix:** decouple the stranding marker from the guardian-tunable operational `minSwapAmount`. Options, cleanest first: +- Arm/clear `poke()` against the **immutable `MIN_SWAP_FLOOR`** instead of `FACTORY.minSwapAmount()`. The marker then only tracks "a non-trivial balance is parked," which the guardian cannot move. +- Or snapshot the arming threshold in storage when `strandedSince` is first set and clear only if balance falls below *that* snapshot. +- Or only clear on `balance == 0` / an actual observed decrease (track last-seen balance). + +Whichever is chosen, add an invariant/fuzz action that raises `minSwapAmount` mid-stranding and asserts `strandedSince` and the SWEEP_DELAY deadline are preserved. + +--- + +## F2 (P2) — Cross-chain replay of the link attestation + +`isValidSignature` binds only to `address(this)` (`:254`): `bound = keccak256(abi.encodePacked(address(this), hash))`. No `block.chainid`. The design doc claims "no cross-contract replay," but cross-**chain** replay is uncovered. Monerium EURe is deployed on multiple chains (Ethereum, Gnosis, Polygon, Arbitrum, …). Clone addresses are CREATE2-deterministic in the factory address + salt + init code; if the factory lands at the same address on two chains (common when teams want consistent addresses) and the same salt is used, the clone address collides, and **one attestor signature validates the Monerium link on both chains**. An attestation minted to link the mainnet forwarder would also validate a link of the identical Gnosis-side address (possibly to a different profile). + +**Status:** the fork test pins mainnet only; whether multichain deployment is intended is **unverified-hypothesis**. If it ever is, this rises to P1. It is cheap to close now. + +**Fix:** `bound = keccak256(abi.encodePacked(block.chainid, address(this), hash))` and mirror it in the off-chain attestor signer. + +--- + +## F3 (P2) — Oracle staleness omits round-completeness + +`_minOut` (`:337-339`) checks `answer <= 0` (handles negative and zero — good) and `updatedAt` age, but not `answeredInRound >= roundId` nor `roundId != 0`. On a Chainlink feed that carries a stale answer forward in a stuck round, `updatedAt` can still look fresh. `minOut` is the sole on-chain price protection, so a wrong-but-recent round weakens it within the `SLIPPAGE_BPS` band. Defense-in-depth. + +**Fix:** read `roundId`/`answeredInRound` from `latestRoundData` and require `answeredInRound >= roundId` and `roundId != 0`. + +--- + +## F4 (P2) — Permissionless swap path is MEV-exposed + +The keeper is planned to submit `swapAndForward` via private orderflow (plan §3). The permissionless branch (`:286-290`, anyone after `TRIGGER_DELAY`) is not, and executes with `amountOutMinimum = minOut` = worst allowed (1%). A searcher can call it in the public mempool and sandwich to the `SLIPPAGE_BPS` floor. A rando can also `poke()` immediately after any deposit to start the 24h clock (arming is intended/permissionless), then extract ≤1% once the keeper is ≥24h down. Within the documented "worst case within slippage bound," but it is a per-swap value leak specific to the fallback path and should be documented (and bounded by keeping `SLIPPAGE_BPS` tight / keeper promptness). + +**PoC confirmed:** a `rando` call after `TRIGGER_DELAY` with the router paying exactly `minOut` forwards the worst-allowed amount to `destination`. + +--- + +## F5 (P2) — Spec/code encoding drift + raw-digest signing + +Variant §3.3 pseudocode: `keccak256(abi.encode(address(this), LINK_HASH))`. Code (`:254`): `abi.encodePacked`. Packed encoding of `(address, bytes32)` is unambiguous (both fixed-length), so this is **safe**, but the two documents disagree, and the off-chain attestor MUST match the code exactly. Additionally, the contract `ecrecover`s over `bound` directly — the attestor signs the **raw 32-byte digest**, not an EIP-191 `personal_sign` of it (the test uses `vm.sign(pk, bound)` accordingly). A signer that uses `personal_sign` will produce non-validating links. + +**Fix:** reconcile the spec to `abi.encodePacked`, and document "attestor signs the raw `bound` digest (no EIP-191 prefix)". + +--- + +## F6 (P2) — `strandedSince` reset discards `perSwapCap` remainder + +`swapAndForward` unconditionally sets `strandedSince = 0` on success (`:328`). When `amountIn` is capped by `perSwapCap` and a remainder ≥ `minSwapAmount` stays, the marker is nonetheless cleared. The leftover then needs a fresh `poke()` + a full `TRIGGER_DELAY` before the permissionless path can move the next chunk, so a balance above the cap drains one cap-chunk per (poke + 24h) cycle if the keeper is absent. Liveness slowness, not a loss. + +**Fix (optional):** after the swap, if the remaining EURe balance ≥ `minSwapAmount`, leave `strandedSince` untouched (or reset to `block.timestamp`) so the remainder stays enrolled in the permissionless schedule. + +--- + +## F7 (P2) — `RECOVERY_HASH` non-custody constraint (registry T1 guardrail) + +`RECOVERY_HASH` is `bytes32(0)` (disabled) in all current configs, so **no live issue**. But the whole non-custody argument for a whitelisted hash depends on the message being **fixed/parameterless** — the same reason the link hash is safe and a redeem order is not. If T1 reveals that Monerium's recovery message contains variable fields (amount, IBAN, bank account), a single compile-time `RECOVERY_HASH` is either useless (hash varies per recovery) or unsafe if broadened to a scheme-match. This is not a finding against current code; it is a **hard constraint on resolving T1**: only enable `RECOVERY_HASH` if the recovery message is parameterless, otherwise the constrained-1271 safety property does not carry over. Worth stating explicitly in the registry T1 row. + +--- + +## F8 (P2) — EURe transfer-restriction assumption undocumented/untested + +EURe is a regulated e-money token with a compliance controller that can restrict transfers. The design handles the USDC (Circle) blacklist (atomic revert keeps funds as EURe). It does **not** document the case where the forwarder's *own* address (or `fallbackAddress`) is EURe-frozen: then `sweepStrandedEure` (`:353`) and client `sweep` (`:382`) both revert `TransferFailed` and funds are genuinely stuck. The mocks model no EURe transfer restriction, so this is neither documented nor tested. Out of contract scope to fix, but the "EURe always transferable for the forwarder/fallback" assumption should be stated (and covered in ops/terms). + +--- + +## F9 / F10 (P2 nits) + +- **F9:** `_setMinSwapAmount` (`Factory.sol:121`) — `value < MIN_SWAP_FLOOR || value > perSwapCap && perSwapCap != 0` is correct only because `&&` binds tighter than `||` (and the construction ordering sets cap after min while cap==0). Add parentheses: `value < MIN_SWAP_FLOOR || (perSwapCap != 0 && value > perSwapCap)`. +- **F10:** Invariant §2.3.3 wording says "at most the two whitelisted hashes"; the code accepts up to three constants (`LINK_HASH_191`, `LINK_HASH_RAW`, `RECOVERY_HASH`) = two *messages*, three *hashes*. Align the wording. + +--- + +## Spec-vs-code invariant check (plan §2.3 + §5) + +| Invariant / disposition | Status | +|---|---| +| §2.3.1 assets leave only via enumerated paths | **Holds.** Exit points: router pull (approved `amountIn`, `Overspend`-checked, reset to 0), USDC fee→`FEE_RECIPIENT` (≤`feeBps`, on swap delta only), USDC→`destination`, EURe→`fallbackAddress` (delayed), fallback `sweep`. Nothing else. | +| §2.3.2 no delegatecall/selfdestruct, CALL-only, value==0, reentrancy-guarded, safe-ERC20 | **Holds.** `nonReentrant` on all fund-movers; low-level calls carry no value; safe-transfer return-data handling present. | +| §2.3.3 1271 validates only whitelisted hashes, none authorize Vortex asset movement | **Holds** (wording nit F10). Redeem orders cannot validate. | +| §2.3.4 guardian delay-only, fallback client-only, non-upgradeable | **Mostly holds; F1 breaches the spirit** — guardian gains an unbounded freeze over permissionless recovery. Deploy-time `destination` trust is the documented S0 residual (guardian sets `destination` at `deployForwarder`; only fallback can change it after — contract correctly blocks post-deploy guardian changes). | +| §2.3.5 `strandedSince` not manipulable to skip/extend delays | **Breached — see F1.** Guardian can reset via a synthetic "balance drop." | +| R03 strandedSince marker | Implemented; F1 caveat. | +| R05 pause protective-only, never traps fallback | Holds — `sweep`/`sweepStrandedEure`/`setDestination` ungated by pause; invariant test covers fallback-sweep-while-paused. | +| R09 unsolicited tokens | Holds — fallback `sweep(token,to)`; unsolicited USDC forwarded to `destination` (fee applied to swap delta only, not to unsolicited USDC — correct). | + +**Factory/clone lifecycle:** `deployForwarder` clones + initializes atomically and is `onlyGuardian` (no initialize front-run — verified). `initialize` is `msg.sender == FACTORY` gated and one-shot; implementation self-bricks in its constructor (test-covered). CREATE2 address cannot be squatted by third parties (deployer = factory). `predictAddress` matches deployment (test-covered). Two-step guardian transfer is sound; `transferGuardian(0)` just cancels a pending transfer. Clones read `FACTORY.guardian()` dynamically, so a transfer re-points every clone with no per-clone migration. `implementation` is immutable (no swap). All good. + +## Test gaps (item 6) + +Named, missing tests (each maps to a finding or an untested branch): + +1. **Guardian raises `minSwapAmount` mid-stranding** — the exact F1 gap; no test toggles the threshold against an armed marker. +2. **Cross-chain replay** — untestable until a chainid is in the bound (F2); add once fixed. +3. **Oracle negative/zero answer** — `InvalidPrice` (`:338`) is never triggered; tests only exercise the stale (`updatedAt`) path. +4. **`RECOVERY_HASH` enabled branch** — every config uses `recoveryHash: bytes32(0)`, so the `isRecovery` path (`:237`) and its "recovery hash only, nothing else" property are entirely untested. +5. **Signature malleability / v** — the high-s check (`:251`) and `v ∉ {27,28}` (`:252`) rejections are untested; only wrong-signer and foreign-hash are covered. +6. **`signature.length != 65`** rejection (`:239`) untested. +7. **`guardianPaused` does NOT block `sweepStrandedEure`** — the "recovery during incident" property is asserted only for the fallback `sweep`, not the permissionless dead-man path. +8. **EURe transfer reverts** (compliance freeze) on the sweep/exit paths (F8) — mocks never fail a transfer. +9. **Fee rounding to zero** for dust swaps (`fee = usdcReceived*feeBps/BPS` floors to 0). +10. **`setFallbackAddress`** authority/gating (only `setDestination` is covered for fallback authority). +11. **Perpetual-remainder liveness** (F6): balance > `perSwapCap`, keeper absent — assert how many (poke + `TRIGGER_DELAY`) cycles are needed. + +## Notes that are NOT findings + +- Deploy-time `destination` correctness is the documented S0 provisioning trust (variant §4, plan R01); the contract cannot verify it and relies on the off-chain manifest + client confirmation. Correctly restricted post-deploy. +- Reentrancy: EURe/USDC are not ERC-777; all fund-movers are `nonReentrant`; a hooked token could reenter only ungated no-op functions (`poke`) with no harmful effect. The router-reentrancy guard is test-covered. +- Registry placeholders (fees, timings, T1–T5) are known-open and not reported as findings. + +--- + +## Author dispositions (2026-07-17) + +| Finding | Disposition | Resolution | +|---|---|---| +| F1 (P1) guardian disarms dead-man via minSwapAmount raise | **Fixed** (commit eff320f68) | `poke()` arms/clears against immutable `MIN_SWAP_FLOOR`; regression test added | +| P2: no chainid in EIP-1271 binding | **Fixed** | Binding is now `keccak256(chainid ‖ address(this) ‖ hash)`; cross-chain replay test added; backend attestor updated; **re-validated against Monerium sandbox (201, linked)** | +| P2: two accepted link-hash variants | **Fixed** | Narrowed to `LINK_HASH_191` only — G0 sandbox confirmed Monerium presents EIP-191; raw-variant rejection tested | +| P2: strandedSince reset discards perSwapCap remainder | **Fixed** | Swap re-arms the marker when post-swap balance ≥ floor; test added | +| P2: oracle round-completeness (answeredInRound) | **Rejected** | `answeredInRound` is deprecated by Chainlink for OCR feeds; `answer > 0` + `updatedAt` staleness ceiling is the current recommended validation. Zero/negative-answer test added | +| P2: permissionless swap path MEV-exposed | **Accepted-documented** | Bounded by oracle `minOut` by design; the permissionless path is a liveness fallback, not the normal path. Noted in security spec | +| P2: spec/code drift on attestation digest encoding | **Fixed** | Variant doc §3.3 sketch aligned with code (encodePacked, chainid, single hash) | +| P2: RECOVERY_HASH non-custody depends on parameterless recovery message | **Accepted** | Requirement added to registry T1: enable only if Monerium's recovery message is parameterless (or parameters are payout-neutral); else keep disabled | +| P2: EURe compliance-freeze behavior unmodeled | **Accepted-documented** | Added to variant doc failure notes: issuer freeze ⇒ nothing moves until resolved; inherent to e-money tokens | +| Test gaps (11 named) | **Closed (9) / N/A (2)** | Added: threshold-raise regression, RECOVERY_HASH-enabled branch, oracle answer≤0, 1271 malleability + length + bad-v, cross-chain replay, raw-variant rejection, cap-remainder re-arm. N/A: EURe-transfer-restriction assumption (documented instead), fee-rounding edge (covered by existing pro-rata unit tests backend-side) | diff --git a/docs/prd/monerium-b2b-implementation-plan.md b/docs/prd/monerium-b2b-implementation-plan.md new file mode 100644 index 000000000..f08063d16 --- /dev/null +++ b/docs/prd/monerium-b2b-implementation-plan.md @@ -0,0 +1,99 @@ +# Implementation Plan: Monerium B2B Zero-Touch Onramp + +**Date:** 2026-07-17 +**Basis:** [B2B variant](./monerium-eur-usdc-onramp-b2b-variant.md) (as updated 2026-07-17) + [re-review dispositions](#5-re-review-dispositions-for-the-b2b-build) below +**Deferred parameters:** every placeholder value in this plan is tracked in the [deferred-decisions registry](./monerium-onramp-deferred-decisions.md) — implementation does not block on them. + +**Locked scope decisions (2026-07-17):** B2B variant first (consumer flow is phase 2, out of this plan). Fallback address mandatory (single tier). Whitelabel API directly, developed against sandbox during MSA negotiation. Adversarial review parallel to implementation. + +## 1. Deliverables overview + +| # | Deliverable | Where | +|---|---|---| +| D1 | `contracts/` Foundry package: `VortexForwarder` implementation + `VortexForwarderFactory` + tests (unit, mainnet-fork, invariant) | new top-level `contracts/` (not a bun workspace) | +| D2 | Backend: Monerium whitelabel service + persistent account/deposit/execution models + keeper | `apps/api` | +| D3 | Ops: config manifest publication + verifier script, monitoring/alerts, runbooks | `contracts/script`, `apps/api`, docs | +| D4 | Terms inputs: disclosure texts, penny-test + dormancy runbook | docs (with G2/partner) | +| D5 | Parallel adversarial review of spec + code | review agent round | + +## 2. Contract architecture (D1) + +### 2.1 Topology + +Each client needs their **own address** (the IBAN links to it), so per-client contracts are unavoidable. Pattern: **EIP-1167 minimal proxies** (clones) of an immutable `VortexForwarder` implementation, deployed via `VortexForwarderFactory` (CREATE2, deterministic addresses), initialized atomically in the deploy transaction. + +```text +VortexForwarder (implementation, immutable): + immutables (implementation-level): EURE, EURC, USDC, ROUTER, PATH params, ORACLE, + ORACLE_DECIMALS, MAX_ORACLE_AGE, SLIPPAGE_BPS, MAX_FEE_BPS, FEE_RECIPIENT, + ATTESTOR, GUARDIAN (Vortex ops), FACTORY, MIN_SWAP_FLOOR, CAP_CEILING, + SWEEP_DELAY, TRIGGER_DELAY, LINK_HASH, [RECOVERY_HASH — pending T1] + per-clone storage (set once by factory in deploy tx): + destination, fallbackAddress, feeBps, initialized + mutable state: strandedSince (R03 marker), accountPaused (guardian OR fallback), + destination (fallback-updatable), fallbackAddress (fallback-updatable) +``` + +### 2.2 Functions + +- `initialize(destination, fallbackAddress, feeBps)` — factory-only, once, in the deploy tx. `feeBps ≤ MAX_FEE_BPS`; `destination`, `fallbackAddress` nonzero, mutually distinct from token/router/oracle addresses. +- `isValidSignature(bytes32 hash, bytes sig)` — returns magic value iff `hash == LINK_HASH` and `sig` is the ATTESTOR's signature over `keccak256(address(this), LINK_HASH)` (plus, pending T1, the analogous check for `RECOVERY_HASH`). Everything else fails. +- `poke()` — permissionless; records `strandedSince = block.timestamp` iff EURe balance ≥ minSwapAmount and `strandedSince == 0`. Cleared on successful swap or balance dropping below threshold. This is the enforceable start time for both delayed mechanisms (fixes R03 for this build). +- `swapAndForward()` — callable by GUARDIAN/keeper any time, or **anyone** once `strandedSince` is older than TRIGGER_DELAY. Atomic: oracle-checked `minOut` (PRD v2 §7.3 math verbatim), contract-constructed `exactInput` calldata (pinned path, recipient = self), exact approval + reset, delta checks, fee skim (pilot 0), full USDC balance → `destination`. Reverts as a unit (blacklisted destination ⇒ funds stay EURe). +- `sweepStrandedEure()` — permissionless once `strandedSince` older than SWEEP_DELAY; transfers full EURe balance to `fallbackAddress` (never `destination` — CEX rule). +- `fallbackOnly` functions: `setDestination`, `setFallbackAddress`, `setAccountPaused(bool)`, `sweep(token, to)` (any token incl. EURe/USDC/unsolicited — R09). +- `guardianOnly`: `setAccountPaused(bool)` per clone (compliance holds, dormancy gate — R05) and factory-level `setGlobalPaused(bool)`. Guardian pause is protective-only: it can never move funds, change config, or block `fallbackOnly` functions (fallback sweep/exit must work even when paused — invariant). +- Operational params (`minSwapAmount`, `perSwapCap`) live on the factory, guardian-settable within immutable floor/ceiling (R10 invariant table in the contract spec). + +### 2.3 Invariants (audit targets, adapted from PRD v2 + re-review) + +1. Assets leave a clone only via: swap (router, minOut-checked, output to self), USDC→`destination`, fee ≤ feeBps→FEE_RECIPIENT, EURe→`fallbackAddress` (delayed sweep), or `fallbackOnly` sweep. Exhaustive. +2. No delegatecall, no selfdestruct, CALL only, `value == 0`, reentrancy-guarded, safe-ERC20. +3. `isValidSignature` validates at most the two whitelisted hashes; neither authorizes asset movement by Vortex. +4. Guardian powers are delay-only; fallback powers are client-only; nothing is Vortex-upgradeable. +5. `strandedSince` cannot be manipulated to skip delays (monotonic per stranding episode; reset only by swap success/balance drop). + +## 3. Backend (D2) + +New `apps/api` module (not the one-shot ramp state machine), per PRD v2 §11 adapted: + +- **Models:** `MoneriumAccount` (profileId, iban, forwarderAddress, configVersion, status), `FiatDeposit` (moneriumOrderId unique, mint tx `(chainId, txHash, logIndex, blockHash)`, amounts, compliance status), `ConversionExecution` (includedDepositIds, eureIn/usdcGross/fee/usdcNet, txHash, status). +- **Whitelabel client:** profile creation, corporate KYB submission (mechanism pending T3 — build against sandbox, abstract the KYB step), address link (`POST /addresses` with attestor signature), IBAN issuance, webhook ingestion. Auth-layer abstracted; sandbox first. +- **Webhooks:** HMAC over raw bytes, constant-time compare, **durable persist before 200** (R06), webhook-ID dedup, forward-only status transitions. +- **Attribution (R04):** an execution record snapshots the EURe balance and the set of `FiatDeposit`s with mint block ≤ execution block that are not yet allocated; allocation pro-rata, floor 6 dp, remainder to largest deposit. Deposits discovered later join the next execution. On-chain balance = safety source; order IDs = accounting source. Per-forwarder serialization via Postgres advisory lock. +- **Keeper:** mint detection (webhook + Transfer watcher), `poke()` submission, `swapAndForward()` via private orderflow, nonce management, stale-tx replacement, retries with alerting. +- **Dormancy gate (R05):** backend job pauses accounts (guardian per-clone pause) after P5 days without a successful forward; unpause on partner re-confirmation. +- **Notifications:** after N confirmations with block-hash re-verification; correction path on reorg. + +## 4. Phases + +- **Phase 0 — G0 spike (parallel with Phase 1):** whitelabel sandbox E2E: deploy forwarder (testnet), attestor-sign link, `POST /addresses`, verify Monerium's EIP-1271 validation passes, mint test EURe, observe. Chainlink EUR/USD weekend behavior (T2). Router pin + EURC hop fee tiers (P10). Reproducible liquidity baseline. **Send T1 question to Monerium tech now.** +- **Phase 1 — Contracts:** D1 complete with fork tests against real pools (incl. V1-token poisoning tests) and invariant/fuzz suite on §2.3. Exit: green suite + spec-code adversarial review round (D5) started. +- **Phase 2 — Backend:** D2 against sandbox; integration tests with mocked/sandbox Monerium; manifest publication + verifier (R01: documented as consistency evidence, not a trust root). +- **Phase 3 — Ops + terms:** monitoring (association changes at Monerium, executable-depth quotes, stranded balances, dormancy), runbooks (incident pause, 02:00-UTC vulnerability, penny test), disclosure/terms drafts to G2 + partner. +- **Phase 4 — Gates + pilot:** G1 written package, G2 sign-off, G3 audit, then G4 invite-only pilot (fee 0, conservative caps). + +Phases 0–2 are engineering-internal and start immediately; 3 runs alongside; 4 depends on externals. + +## 5. Re-review dispositions for the B2B build + +The re-review (R01–R12) targeted the consumer PRD v2; dispositions here cover the B2B build. The consumer flow re-review response is deferred to phase 2 (out of scope of this plan). + +| ID | Disposition (B2B) | Resolution | +|---|---|---| +| R01 | Accept | Manifest + verifier ship (D3) but are documented as consistency evidence with no independent trust root; S0 claim in variant doc already scoped to "detectable, not prevented". No cryptographic fix claimed | +| R02 | N/A here | No passkeys in the B2B flow. Owned by consumer phase 2 | +| R03 | Accept — resolved | `strandedSince` poke marker (§2.2) gives both delayed mechanisms an enforceable on-chain start time | +| R04 | Accept — resolved | Snapshot-based allocation rule + advisory-lock serialization (§3) | +| R05 | Accept — resolved | Per-clone guardian pause in contract state (§2.2), protective-only invariant; dormancy gate and compliance holds build on it | +| R06 | Accept — resolved | Durable webhook persistence before 200 (§3) | +| R07 | Accept | Fallback-initiated config changes (destination/fallback updates) are evented; verifier treats owner-authorized changes as expected state transitions, not incidents | +| R08 | N/A here | No opaque owner signatures — no user keys. The analogous surface (fallback-address key compromise) is disclosed in client terms (client self-custody responsibility) | +| R09 | Accept — resolved | `fallbackOnly sweep(token, to)` handles unsolicited tokens; unsolicited USDC is swept to destination with the next forward (documented); accounting treats non-Monerium EURe inflows as unattributed (flagged, not allocated to deposits) | +| R10 | Accept — resolved | §2.3.4/§2.2 role + parameter bounds table is part of the contract spec; audit target | +| R11 | Accept | "Always exit" language replaced: exit guarantees are scoped to fallback-key availability + issuer backstop (best-effort pending T1) | +| R12 | Accept | This plan + updated variant doc are the normative spec for the B2B build; the PRD v2 appendix format stays as-is for the consumer flow | + +## 6. What this plan deliberately defers + +Everything in the [registry](./monerium-onramp-deferred-decisions.md): fee value, all timing/size parameters (P1–P10), T1–T5 clarifications, G1 written package, G2 legal, partner terms. None block Phases 0–2. diff --git a/docs/prd/monerium-b2b-terms-inputs.md b/docs/prd/monerium-b2b-terms-inputs.md new file mode 100644 index 000000000..cbecb5df1 --- /dev/null +++ b/docs/prd/monerium-b2b-terms-inputs.md @@ -0,0 +1,106 @@ +# Monerium B2B Onramp — Terms & Disclosure Inputs (D4) + +**Status:** engineering inputs for G2 legal review and the partner agreement — not final legal +text. Every bracketed value **[ID: …]** is a placeholder tracked in the +[deferred-decisions registry](./monerium-onramp-deferred-decisions.md); G2/partner own the final +wording, engineering owns the factual accuracy of the mechanics described. + +**Sources:** [b2b-variant doc](./monerium-eur-usdc-onramp-b2b-variant.md) §6, +[implementation plan](./monerium-b2b-implementation-plan.md) §5 (R08/R11 dispositions). + +## 1. Redemption limitation (registry B6 — committed to Monerium) + +Monerium's acceptance of the attestor pattern is conditional on clients being told explicitly +that the setup limits direct redemption. Commitment made in the TG thread 2026-07-17; this +disclosure is **mandatory** in the client terms. + +Draft text: + +> 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, as a last resort, use Monerium's recovery process, which pays out only +> to your own verified bank account. + +Notes for G2: the issuer recovery backstop is best-effort until its technical mechanism is +settled **[T1: recovery-message format unanswered — if unresolved at deploy, recovery for +contract addresses fails closed and the fallback address is the only redemption path; the +disclosure must not overpromise the backstop]**. + +## 2. Destination warranty & CEX rotation liability (registry B5) + +Allocation follows the traditional payout-processor model: a wrong/closed destination account is +the instructing party's loss; contractual allocation is the only mechanism that can hold this +risk, since a CEX deposit address's continued validity is not verifiable on-chain. + +Structure to draft: + +- **Client/partner warrants** that the named `destination` is valid, under the client's control + (or a deposit address of an account under the client's control), and that they will notify + Vortex of any change **before** further deposits. +- **Client/partner bears** losses from destination rotation, closure, or mis-crediting by the + destination platform. +- **Exchange-address attestation:** for CEX destinations the client attests awareness of + rotation and minimum-deposit behavior. +- **Vortex diligence commitments** (the consideration for the warranty): a verification transfer + before activation **[B2: 5 USDC]**; automatic pause after prolonged inactivity pending + re-confirmation (§3); minimum forward size at or above the destination's minimum deposit + **[P6: minSwapAmount, €25 floor]**; and never sending unconverted EURe to the destination. +- **Destination changes are client-only:** only the client's fallback key can change the + destination on-chain; Vortex cannot redirect funds (see §6). + +## 3. Dormancy re-confirmation (registry B5, P5) + +> If no conversion completes for **[P5: 60 days]**, forwarding pauses automatically and resumes +> only after you (or the partner on your behalf) re-confirm that your payout address is still +> valid. Deposits made while paused remain in your forwarding account and convert after +> re-confirmation; your fallback-address rights are unaffected by the pause. + +Mechanics reference for the drafters: `docs/runbooks/monerium-b2b-dormancy.md`. The +re-confirmation channel (partner API ping vs written confirmation) is a partner-agreement +decision **[B5]**. + +## 4. Fee disclosure structure (registry B1, P1, P2) + +Fee and slippage are disclosed **separately** — one is deterministic, the other a worst-case +market bound: + +- **Service fee:** a per-client percentage fixed at account creation and immutable thereafter, + assessed on the gross USDC output of each conversion. Current pilot value **[B1: 0]**; + contractual ceiling equal to the on-chain immutable cap **[P2: MAX_FEE_BPS = 100 bps = 1%]**. +- **Conversion bound (not a fee):** each conversion delivers **at least** the Chainlink EUR/USD + reference rate minus **[P1: SLIPPAGE_BPS = 100 bps = 1%]**, or it does not execute at all + (deposits then wait and retry). This bound covers market execution and both stablecoins' + deviation from their pegs; it is enforced by the contract, assuming an honest oracle — it is + not a principal guarantee under oracle failure or a stablecoin collapse beyond the bound. +- Batching: deposits arriving between conversions are converted together; fee and output are + allocated pro-rata by deposit amount, so batching never changes a client's effective rate. + +## 5. Processing SLA (registry B3) + +Placeholder wording pending the business decision: + +> Deposits at or above the minimum **[P6: €25]** convert **[B3: within 1 business hour]** under +> normal market conditions. Conversions execute on weekends; the EUR/USD reference rate updates +> less frequently outside FX market hours **[T2/P8: observed weekend gaps up to 48 h; oracle +> staleness ceiling 52 h]**, so weekend conversions may execute at a rate up to that age — +> always within the conversion bound of §4. Deposits below the minimum accumulate until the +> minimum is reached. + +Include: SLA is a service target, not a guarantee; keeper outages beyond +**[P4: TRIGGER_DELAY = 24 h]** open a permissionless execution path so conversion does not +depend on Vortex's liveness. + +## 6. Vortex-powers and self-custody disclosures (plan §5 R08/R11) + +- **What Vortex can do:** deploy the account, run the conversion, pause it (per-account and + globally), and tune bounded operational parameters. **What Vortex cannot do:** move, redeem, + or redirect funds; every exit target is client-controlled. Pauses can delay conversions but + never block the client's fallback-address rights or the delayed automatic sweep to the + fallback address **[P3: 60 days]**. +- **Fallback-key responsibility (R11 — do not overpromise):** exit guarantees are scoped to the + client's continued control of their fallback key, plus the issuer backstop (best-effort, + §1 note). Loss of the fallback key combined with a broken destination is an ordinary + self-custody residual risk and is borne by the client. +- **Deposit acceptance:** Vortex cannot prevent inbound SEPA to an issued IBAN; deposits during + a pause accumulate as EURe at the forwarding address under the protections above. diff --git a/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md b/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md new file mode 100644 index 000000000..6bb1c16e8 --- /dev/null +++ b/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md @@ -0,0 +1,411 @@ +# Architecture Re-review: Quoteless EUR → USDC Onramp via Monerium + +**Reviewed document:** [monerium-eur-usdc-onramp.md](./monerium-eur-usdc-onramp.md) v2.0 +**Prior review:** [monerium-eur-usdc-onramp-architecture-review.md](./monerium-eur-usdc-onramp-architecture-review.md) +**Review date:** 2026-07-13 +**Review status:** Materially improved; five blocking design gaps remain +**Recommended decision:** **No-go for production implementation freeze or security audit until R01–R05 are resolved in the specification.** G0 prototypes may proceed; external gates G0–G4 must still pass before launch. + +## 1. Executive conclusion + +Version 2 is a serious improvement. It retracts the unsafe end-to-end non-custody claim, removes the generic router, CoW, ERC-4337, and automatic redeem validator, chooses one module topology, and models the IBAN as a persistent account rather than a terminal ramp. The resulting v1 is credible and auditable in principle. + +The revised composition is nevertheless not ready to implement exactly as written. The remaining blockers are narrower than in v1, but concrete: + +1. the configuration manifest has no trust root independent of the provisioning system it is supposed to detect; +2. a second passkey is incorrectly treated as RP-independent recovery; +3. the delayed permissionless fallback cannot determine when an ERC-20 balance crossed the threshold from the proposed state; +4. a live-balance sweep can consume deposits that the database has not attributed, so the proposed pro-rata accounting is not yet deterministic; +5. the contract state does not contain the per-account guardian pause required by the incident, sanctions, limit, and permissionless-fallback behavior. + +The core on-chain safety direction is now sound: fixed token addresses, a fixed path and router, module-constructed calldata, oracle-derived `minOut`, `CALL` only, exact approvals, delta checks, atomic fee/forwarding, and owner-authorized migration. The remaining work is mostly about making the surrounding claims and state transitions match what the system can actually enforce. + +## 2. Cognitive-load assessment + +The revised v1 is now mostly `🧠`: one Safe deployment path, one conversion module, one route, one fallback handler, and one persistent backend model. The largest remaining `🤯` area is the interaction among live ERC-20 balances, Monerium orders, webhook ordering, permissionless execution, compliance holds, and accounting attribution. Those facts currently live in separate sections, but correctness depends on reasoning about all of them at once. R03–R06 propose one explicit lifecycle that reduces that cross-section load. + +## 3. Disposition of the original findings + +| Original | Re-review status | Comment | +|---|---|---| +| F01 | **Resolved at claim level; external gate open** | S1 now admits Vortex/Monerium authority. G1 remains a genuine launch gate, not an implementation detail. | +| F02 | **Partially resolved** | The stage-based trust model is good. The manifest/verifier does not yet make provisioning fraud independently detectable; see R01. | +| F03 | **Mostly resolved** | The link signature is no longer described as configuration consent. Opaque owner-signature risk after onboarding remains under-modeled; see R08. | +| F04 | **Mostly resolved** | Singleton plus Safe-keyed configuration is coherent. Pause state and parameter governance need completion; see R05 and R10. | +| F05 | **Resolved** | The automatic redeem validator and custom fallback behavior are removed. | +| F06 | **Mostly resolved** | Internal calldata and a pinned route establish the intended swap constraint. The liveness fallback is not implementable from the specified state; see R03. | +| F07 | **Resolved** | CoW is correctly deferred to a separate design. | +| F08 | **Partially resolved** | Long-lived account/deposit/execution objects are correct. The execution-to-deposit ledger is not yet race-safe; see R04. | +| F09 | **Resolved in design; validation open** | Math, rounding direction, assumptions, and loss wording are materially improved. Feed pinning and weekend behavior correctly remain in G0. | +| F10 | **Resolved** | Fee recipient, ceiling, per-account value, and pilot behavior are structurally defined. | +| F11 | **Partially resolved** | A recovery owner is mandatory, but one allowed choice is not RP-independent; see R02. | +| F12 | **Resolved in design; validation open** | Caps are correctly treated as availability controls and executable-depth measurement is required. | +| F13 | **Partially resolved** | Webhook authentication, deduplication, reorg policy, and database locking are covered. Durable receipt and attribution races remain; see R04 and R06. | +| F14 | **Partially resolved** | Migration is credible. The specified contract does not implement the claimed per-account guardian pause; see R05. | +| F15 | **Partially resolved** | G1/G2 are appropriate. Unsolicited EURe, the operational limit, and permissionless execution during a hold need explicit treatment; see R04 and R05. | +| F16 | **Mostly resolved** | Minimums, accumulation, gas cost, and destination validation are now explicit. Full-balance USDC behavior still needs a product rule; see R09. | +| F17 | **Mostly resolved** | Most absolute wording is corrected. S4 and the final statement still need the owner-signature caveat; see R08 and R11. | +| F18 | **Resolved** | The design sacrifice was applied effectively. | + +## 4. Priority summary + +| ID | Priority | Finding | Blocks | +|---|---|---|---| +| R01 | P0 | The manifest verifies consistency, not honest provisioning | S0 fraud-detection claim, audit scope | +| R02 | P0 | A second passkey is not RP-independent recovery | Recovery guarantee, onboarding UX | +| R03 | P0 | The delayed permissionless fallback has no enforceable start time | Contract design, liveness claim | +| R04 | P0 | Live balance sweeps race deposit attribution | Financial accounting, API correctness | +| R05 | P0 | Per-account guardian pause and operational-limit semantics are absent | Compliance holds, incident response | +| R06 | P1 | Webhooks must be durably accepted before returning `200` | Event loss, reconciliation | +| R07 | P1 | Mutable owner configuration conflicts with continuous manifest verification | Monitoring, false incident alerts | +| R08 | P1 | Opaque passkey owner signatures are omitted from the post-mint threat model | Security statement, user compromise | +| R09 | P1 | Full-USDC sweep and unsolicited EURe lack product/accounting rules | User expectations, ledger correctness | +| R10 | P1 | Operational parameter and role invariants are incomplete | Contract auditability, liveness | +| R11 | P2 | “Always exit” remains stronger than the stated assumptions | Specification accuracy | +| R12 | P2 | The response appendix obscures the normative specification | Maintainability | + +## 5. Detailed findings + +### R01 — The manifest verifies consistency, not honest provisioning + +**Priority:** P0 +**Affected PRD lines:** 64, 81, 130–132 + +#### Concern + +The PRD says a hostile provisioning pipeline is detectable because an open-source verifier checks the deployed Safe against a manifest published in a repository and API. That proves only: + +> deployed state == Vortex-published manifest + +It does not prove: + +> deployed state == independently approved Vortex policy + the user's intended destination and recovery owner + +If the frontend, backend, deployment path, and manifest publisher are compromised together—the S0 scenario—the attacker can deploy a hostile Safe and publish a matching hostile manifest. A verifier operated by the same onboarding backend will report success. A repository plus API is also not necessarily append-only, independently witnessed, or resistant to equivocation between users. + +#### Required resolution + +Choose one honest claim and implement its prerequisites: + +**Option A — independent detection:** + +1. Define a canonical policy schema with allowed Safe singleton/factory/handler/module/signer code hashes, threshold rules, immutable contract coordinates, and parameter bounds. +2. Have releases signed by keys outside the provisioning path, preferably an audit/release multisig with at least one independently operated signer. +3. Publish account manifests to an append-only, externally witnessed log; prevent presenting different histories to different observers. +4. Make the verifier compare live state both to the account manifest **and** to the independently signed policy release. +5. Define how destination and recovery-owner intent enter the trust root. A Vortex-controlled page displaying Vortex-controlled data is not an independent confirmation. +6. Specify who operates the independent check and what causes onboarding to halt if the provisioning backend itself is compromised. + +**Option B — narrower claim:** replace “fraud is detectable before first deposit” with “the deployed configuration is publicly auditable against a Vortex-published record.” Do not count this as a control against full provisioning compromise. + +### R02 — A second passkey is not RP-independent recovery + +**Priority:** P0 +**Affected PRD lines:** 43, 77–78, 96–97, 184–188, 270, 288 + +#### Concern + +The PRD permits “a second passkey on another device” as the mandatory independent recovery owner and later describes `(EOA/hardware/second passkey)` as RP-independent. A WebAuthn credential is scoped to its RP ID. A second device protects against loss of the first device; it does not protect against loss of control of the Vortex RP domain, browser origin, or domain-continuity infrastructure. + +The static recovery page also works only if it can be served from an origin accepted for the original RP ID. “Self-hostable” does not mean domain-independent. + +#### Required resolution + +1. Remove a same-RP passkey from the choices that satisfy the **independent** recovery requirement. +2. Require at least one RP-independent owner: hardware wallet, existing EOA, or offline recovery key. +3. A second passkey may remain as an additional convenience owner, but label it “device-redundant, RP-dependent.” +4. Threat-model the printable EOA: compromised-generation page, screenshots/cloud backup, printing, theft, inheritance, verification that the public address matches the paper secret, and rotation after suspected exposure. +5. Make the fork recovery test exercise the RP-independent owner with every Vortex service and the Vortex domain unavailable. Test the passkey/domain-continuity path separately. + +The W3C WebAuthn specification states that a credential can only be used with the RP ID for which it was registered. Device diversity does not alter that scope. + +### R03 — The delayed permissionless fallback has no enforceable start time + +**Priority:** P0 +**Affected PRD lines:** 107–120, 136–143, 267 + +#### Concern + +The module permits anyone to call only after the Safe's balance has exceeded `minSwapAmount` for `LIVENESS_FALLBACK_DELAY`. The proposed storage contains `initializedAt`, but no timestamp recording when a balance crossed the threshold. An ERC-20 transfer does not call the receiving Safe or module, so the module cannot observe and timestamp the crossing automatically. + +`initializedAt` cannot substitute for this: after an account is older than 24 hours, every new deposit would be immediately permissionless. + +#### Required resolution + +Select and specify one implementable policy: + +**Simplest:** make `swapAndForward` permissionless at all times. The fixed route and `minOut` already bound caller-controlled timing, but legal/compliance must accept that any observer may trigger conversion. + +**Delayed fallback:** add an explicit state machine: + +```text +arm(safe): + require(balance >= minSwapAmount) + if eligibleSince[safe] == 0: eligibleSince[safe] = block.timestamp + +swapAndForward(safe): + authorized keeper may execute immediately + other caller requires eligibleSince != 0 + other caller requires block.timestamp >= eligibleSince + delay + successful execution resets eligibleSince + a balance below threshold resets it without allowing a caller to move it forward +``` + +Define who may arm, whether arming itself is permissionless, how cap-sized partial executions re-arm residual balances, and how threshold/parameter changes affect an existing timer. Add these transitions to invariant and fuzz tests. + +### R04 — Live balance sweeps race deposit attribution + +**Priority:** P0 +**Affected PRD lines:** 88–91, 140–149, 220–242 + +#### Concern + +The database lock serializes Vortex keeper workers, but it cannot serialize external EURe transfers. Consider: + +1. the worker locks the Safe and selects deposits A and B; +2. deposit C mints on-chain before the keeper transaction executes, while its webhook/indexer record is delayed; +3. the module reads the live balance and sweeps A+B+C (subject to the cap); +4. `ConversionExecution.includedDepositIds` contains only A and B; +5. the PRD allocates all output pro-rata to A and B. + +The execution is safe on-chain but wrong in the accounting/API layer. The same problem occurs with direct third-party EURe transfers, delayed order-to-mint reconciliation, cap-sized partial consumption, and multiple issue orders sharing a batch. “On-chain balance is the safety source” does not by itself define accounting ownership. + +#### Required resolution + +Model funding and consumption as an event-sourced asset ledger: + +1. Record every EURe credit as a `FundingLot` keyed by `(chainId, txHash, logIndex)`, including amount, block position, source address, and classification `monerium_issue | direct_transfer | unresolved`. +2. Link Monerium orders to funding lots when evidence becomes available; do not require webhook arrival before the on-chain event can exist. +3. Create `ConversionExecution` from the confirmed execution receipt and emitted `amountIn/usdcOut/fee`, not from the pre-submission plan. +4. Consume funding lots in one documented order—FIFO by canonical block/transaction/log position is simpler than ad hoc pro-rata—and support partial lot consumption at `perSwapCap`. +5. If a consumed lot is not yet linked to a Monerium order, place its output in an accounting suspense bucket and reconcile later. Never silently allocate it to known deposits. +6. Handle execution reorgs by reversing ledger consumption and rebuilding from canonical logs. +7. Specify whether unsolicited EURe is converted, quarantined operationally, or merely classified after unavoidable conversion. + +An optional simplification is to let the keeper pass a bounded `amountIn` selected from its canonical snapshot while the module still constructs every destination/router/path field. This does not remove the need for receipt-based reconciliation, but it reduces accidental inclusion of a just-arrived deposit. + +### R05 — Per-account guardian pause and operational-limit semantics are absent + +**Priority:** P0 +**Affected PRD lines:** 116–120, 138–142, 175–178, 214–216, 248–262 + +#### Concern + +The incident section says the guardian can pause globally or per account, and destination screening says a hit causes a conversion pause. The proposed storage and execution checks contain only: + +- `Config.userPaused`, controlled by the Safe; and +- `globalPaused`, controlled by Vortex. + +There is no guardian-controlled per-Safe pause. Reusing `userPaused` would be unsafe semantically: the guardian must not clear a user's pause, and an owner action must not accidentally clear a compliance/incident hold. + +The pilot's “€1k/user/day operational limit” is also not a control as written. Vortex cannot stop an incoming SEPA payment, `perSwapCap` is proposed at €5–10k, and the permissionless fallback can execute without keeper policy. The document must say whether the limit is Monerium-enforced, contract-enforced, or merely monitored. + +#### Required resolution + +1. Add independent pause domains: + +```solidity +Config { ...; bool userPaused; } +mapping(address safe => bool) guardianPaused; +bool globalPaused; +``` + +2. Require all three to be false before conversion. +3. Define role-specific transitions: Safe owners control only `userPaused`; guardian controls only `guardianPaused` and `globalPaused`. +4. Define whether guardian unpause requires a second role, delay, resolved compliance state, or multisig policy. “Unpause is safe” is true for principal routing but not necessarily for sanctions/compliance obligations. +5. Make the delayed permissionless path honor both guardian pause levels. +6. Replace the ambiguous daily limit with an enforceable rule: a Monerium account limit, an on-chain rolling conversion limit, or an explicitly labeled monitoring/hold threshold. Define behavior for excess deposits and how the user exits. +7. Define the screening window between mint and permissionless eligibility. If the design promises a compliance hold, it needs enough deterministic time to set `guardianPaused` before fallback execution. + +### R06 — Webhooks must be durably accepted before returning `200` + +**Priority:** P1 +**Affected PRD line:** 234 + +#### Concern + +“Immediate `200` + async processing” is safe only if the verified event is committed durably before the response. If the API returns `200` and then crashes before persistence or queue publication, Monerium treats delivery as successful and will not retry it. + +#### Required resolution + +Specify the inbox transaction explicitly: + +1. read raw bytes and required signature headers; +2. validate timestamp/replay window and HMAC using constant-time comparison; +3. insert `{webhookId, webhookTimestamp, rawBody, signatureVersion, receivedAt, processingStatus}` under a unique constraint; +4. commit; +5. return `200` for a committed new event or already-committed duplicate; +6. return non-2xx if verification or durable insertion fails; +7. let a retryable worker process the inbox row and retain an error/dead-letter history. + +Monerium's current documentation signs `webhook-id`, `webhook-timestamp`, and the raw body and retries failed deliveries with the same ID. The PRD's desired controls match the provider; the missing point is ACK ordering. + +### R07 — Mutable owner configuration conflicts with continuous manifest verification + +**Priority:** P1 +**Affected PRD lines:** 123–132, 248–253 + +#### Concern + +The owner may change `destination`, while the manifest contains the “full config” and a continuous verifier checks live state against it. After a legitimate owner change, either: + +- the verifier reports a security incident forever; +- Vortex silently rewrites the manifest, weakening its historical value; or +- the owner must somehow publish an authenticated manifest revision, which is not specified. + +The same issue applies to owner/module changes performed through the disaster-recovery tool. + +#### Required resolution + +Split immutable and mutable evidence: + +- `DeploymentManifest`: immutable setup transaction, code hashes, initial owners/threshold/modules/config, signed policy release. +- `ConfigHistory`: append-only projection of canonical Safe/module events with block/log coordinates and reorg handling. +- `CurrentState`: derived from chain, with a classification of `authorized owner change`, `approved migration`, `unexpected code/state`, or `reorg pending`. + +The verifier should alarm on violations of policy and unexplained state transitions, not on every difference from genesis. Define how owner-authorized changes are authenticated and how the public record preserves old versions. + +### R08 — Opaque passkey owner signatures are omitted from the post-mint threat model + +**Priority:** P1 +**Affected PRD lines:** 64–68, 77–82, 93–97, 280, 297 + +#### Concern + +Appendix A correctly rejects the claim that an extra WebAuthn signature provides meaningful what-you-see-is-what-you-sign consent under a compromised frontend. The same limitation applies later: the passkey is a threshold-1 Safe owner that can disable the module, replace owners, change the destination, or transfer assets. A compromised Vortex origin can ask the user for an opaque passkey ceremony while presenting misleading UI. + +That is not unilateral Vortex authority—the attacker still needs user presence/verification—but it is a material route around the module policy and should not disappear from the S2/S4 threat model. + +#### Required resolution + +1. Add a residual risk: a compromised approved RP origin may induce a user to authorize a malicious Safe owner transaction because the authenticator does not display decoded Ethereum intent. +2. Make routine deposit/status flows never request a passkey assertion. Reserve owner signing for a visibly separate management/recovery flow. +3. Display decoded Safe transaction data, simulation, target code identity, and before/after owners/modules/destination in at least one independently implemented confirmation surface where feasible. +4. Decide whether dangerous owner operations need the RP-independent recovery owner as a second signature. If threshold 1 is retained, state the trade-off explicitly rather than treating the module as the only post-mint path. +5. Add an assumption to the final security statement: the user has not authorized a malicious owner transaction. + +### R09 — Full-USDC sweep and unsolicited EURe lack product/accounting rules + +**Priority:** P1 +**Affected PRD lines:** 143–151, 196–198, 204–216, 230–242 + +#### Concern + +The module charges the fee on `usdcDelta` but forwards the Safe's **full** remaining USDC balance. Therefore pre-existing or accidentally sent USDC is swept to `destination` without appearing in the conversion gross/net accounting. Likewise, anyone can send EURe to the public Safe; the module cannot know whether it came from the user's IBAN. + +This is not necessarily a principal-safety bug—the configured destination receives the assets—but it contradicts the impression that each forwarded amount maps cleanly to Monerium deposits and can create compliance and partner-API discrepancies. + +#### Required resolution + +Choose explicit rules: + +- Prefer forwarding exactly `usdcDelta - fee`, leaving pre-existing USDC under owner control; or state prominently that the Safe is an auto-sweeping account for **all** USDC. +- Treat direct EURe/USDC transfers as first-class ledger events, with source classification and an API representation. +- Decide whether direct EURe is supported, quarantined in accounting, or a disclosed unsupported action that may still convert because the contract cannot distinguish its provenance. +- Add tests with non-zero pre-swap USDC, direct EURe, fee-on/off, capped partial swaps, and late-arriving transfers. + +### R10 — Operational parameter and role invariants are incomplete + +**Priority:** P1 +**Affected PRD lines:** 111–124, 138, 173–178 + +#### Concern + +The storage sketch does not fully define whether operational parameters and keepers are global or per Safe, who may change each value, or which directions are delayed. It also permits contradictory values unless additional invariants are intended—for example `minSwapAmount > perSwapCap`, which permanently prevents execution. + +“Lowering instant, raising behind the timelock” is ambiguous because protective direction differs by parameter: lowering `perSwapCap` is protective, while lowering `minSwapAmount` causes more/smaller executions. + +#### Required resolution + +Define the complete setter table before audit: + +| Parameter | Scope | Invariant | Instant direction | Delayed direction | Role | +|---|---|---|---|---|---| +| `minSwapAmount` | global or per Safe | `MIN_SWAP_FLOOR <= minSwapAmount <= perSwapCap` | explicitly decide | explicitly decide | guardian/timelock | +| `perSwapCap` | global or per Safe | `minSwapAmount <= perSwapCap <= CAP_CEILING` | decrease | increase | guardian/timelock | +| keeper set | global | non-empty unless permissionless-only | removal during incident | addition/rotation policy | multisig/timelock | +| fallback delay | immutable or governed | bounded range | explicitly decide | explicitly decide | constructor/timelock | + +Specify pending-update cancellation, events, timelock identity, guardian rotation, lost-key response, and whether a global singleton creates an accepted all-account blast radius. Add invariant tests for every cross-parameter relationship. + +### R11 — “Always exit” remains stronger than the stated assumptions + +**Priority:** P2 +**Affected PRD lines:** 68, 93–97 + +#### Concern + +S4 says the user can “always exit with assets” given an owner credential, RPC, and funded relayer. Owner control guarantees the ability to authorize and submit a Safe transaction; it does not guarantee that EURe/USDC transfers or Monerium redemption will succeed under issuer freeze, blacklist, token pause/upgrade, chain censorship, or Monerium refusal. + +#### Required resolution + +Change the guarantee to: + +> Given a valid owner credential and transaction-submission path, the user can authorize arbitrary Safe transactions without Vortex and Vortex cannot veto them. Successful asset exit still depends on Ethereum, token contracts/issuers, and—when redeeming—Monerium. + +Add token/issuer restrictions to the S4 residual column. + +### R12 — The response appendix obscures the normative specification + +**Priority:** P2 +**Affected PRD lines:** 274–297 + +#### Concern + +The appendix is valuable review history, but it repeats security decisions and sometimes adds nuance not present in the normative sections. A future implementer now has to decide whether §1–§14 or Appendix A is authoritative. That is avoidable `🧠 → 🤯` load in an already cross-disciplinary specification. + +#### Required resolution + +After this review cycle: + +1. keep the PRD normative and self-contained; +2. move the response matrix to a separate decision/review log or mark it explicitly non-normative; +3. convert every accepted nuance into the applicable normative section; +4. add a compact invariant/role/state-transition appendix generated from the final design. + +## 6. Required acceptance gates for v3 + +Before production implementation freeze or security audit (G0 prototypes may proceed): + +- [ ] R01: manifest trust root and independent-verification claim are made precise. +- [ ] R02: recovery choices guarantee at least one RP-independent owner. +- [ ] R03: permissionless-fallback state machine is implementable and tested on paper. +- [ ] R04: funding-lot and execution-consumption ledger handles late/direct/reorged transfers. +- [ ] R05: user, guardian-account, and global pause domains are represented in storage and transitions. +- [ ] R06: webhook ACK occurs only after durable inbox commit. +- [ ] R07: manifest/config history handles legitimate owner changes. +- [ ] R08: opaque passkey owner-signature risk is included in the threat model and UX. +- [ ] R09: full-USDC and direct-token behavior is an explicit product rule. +- [ ] R10: all roles, setters, bounds, and timelock directions are specified. +- [ ] R11: S4 wording describes transaction authority rather than guaranteed asset exit. +- [ ] G0–G4 remain blocking for their stated implementation/launch stages. + +## 7. Requested author response + +Please respond with a disposition and proposed change for each item: + +| ID | Disposition (`Accept`, `Reject`, `Modify`, `Needs validation`) | Author response / proposed PRD change | +|---|---|---| +| R01 | | | +| R02 | | | +| R03 | | | +| R04 | | | +| R05 | | | +| R06 | | | +| R07 | | | +| R08 | | | +| R09 | | | +| R10 | | | +| R11 | | | +| R12 | | | + +The key requested answer is now narrower than in the first review: can the team define one deterministic lifecycle from “Monerium or direct EURe credit observed” through “eligible/paused,” “on-chain execution,” “canonical receipt,” and “deposit/output ledger allocation,” while preserving an independently recoverable owner and an independently meaningful provisioning record? + +## 8. Primary references checked during re-review + +- [Monerium Whitelabel webhooks](https://docs.monerium.com/whitelabel/) +- [Monerium API](https://docs.monerium.com/api/) +- [Safe fallback handler](https://docs.safe.global/advanced/smart-account-fallback-handler) +- [Safe and passkeys](https://docs.safe.global/advanced/passkeys/passkeys-safe) +- [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) +- [Uniswap v3 SwapRouter source](https://github.com/Uniswap/v3-periphery/blob/main/contracts/SwapRouter.sol) diff --git a/docs/prd/monerium-eur-usdc-onramp-architecture-review.md b/docs/prd/monerium-eur-usdc-onramp-architecture-review.md new file mode 100644 index 000000000..9e2fce802 --- /dev/null +++ b/docs/prd/monerium-eur-usdc-onramp-architecture-review.md @@ -0,0 +1,934 @@ +# Architecture Review: Quoteless EUR → USDC Onramp via Monerium + +**Reviewed document:** [monerium-eur-usdc-onramp.md](./monerium-eur-usdc-onramp.md) +**Review date:** 2026-07-13 +**Review status:** Blocking issues identified; author response requested +**Recommended decision:** **No-go for implementation or audit until the P0 findings are resolved** + +## 1. Purpose of this review + +This document is a deliberately adversarial review of the proposed Monerium EUR → USDC architecture. It is intended to be handed back to the authoring agent for a point-by-point response. + +For each finding, the author should state one of: + +- **Accept** — the PRD will be changed as recommended. +- **Reject** — explain why the concern does not apply and provide evidence. +- **Modify** — propose a different resolution and explain the resulting trust assumptions. +- **Needs validation** — identify the spike, Monerium confirmation, legal opinion, or contract prototype needed to decide. + +The review distinguishes between: + +- on-chain safety after EURe has reached the Safe; +- provisioning and fiat-ingress authority before mint; +- operational liveness and recovery; +- user-facing and legal claims; +- compatibility with Vortex's existing one-shot ramp architecture. + +## 2. Executive conclusion + +The product primitive is credible: a personal IBAN can mint EURe to a linked smart account, and an automated account policy can convert it to USDC. The current proposal, however, overstates the end-to-end security guarantee. + +The central claim — that Vortex can never redirect, withhold, or seize user funds and that a full Vortex compromise can lose only the slippage margin — is not established across the complete system. It currently excludes: + +1. Monerium address and IBAN-management authority; +2. malicious or incorrect Safe provisioning; +3. fallback-handler and recovery authority; +4. generic router calldata and unrelated Safe approvals; +5. passkey/domain dependence when Vortex disappears; +6. fees and future CoW execution; +7. the fact that a reusable IBAN is not a one-shot ramp. + +The architecture can be salvaged, but v1 should be materially smaller and the trust statement should be rewritten as a set of scoped guarantees rather than a single absolute claim. + +## 3. What appears sound + +The following foundations are reasonable, subject to sandbox and contract-level verification: + +- Monerium documents automatic issuance to the wallet linked to a dedicated IBAN. +- Monerium documents off-chain EIP-1271 verification for smart-contract-wallet address linking. +- Safe supports WebAuthn/passkey contract owners. +- Ethereum mainnet has supported the EIP-7951 P-256 precompile since Fusaka. +- Uniswap v3 supports a two-hop exact-input path in one router call. +- An immutable module that constructs tightly constrained calls and verifies token deltas is a sound direction. +- Oracle-based minimum output, strict staleness checks, no delegatecall, exact approvals, atomic forwarding, and invariant/fuzz tests are appropriate defenses. +- Explicitly acknowledging issuer freeze/upgrade powers and fail-safe reverts is correct. + +These strengths do not resolve the findings below, but they make a reduced v1 viable. + +## 4. Priority summary + +| ID | Priority | Finding | Blocks | +|---|---|---|---| +| F01 | P0 | Monerium control plane may redirect future deposits | Non-custody claim, GA | +| F02 | P0 | “Full Vortex compromise” excludes provisioning and fiat ingress | Security model, audit | +| F03 | P0 | The one signature does not bind destination or Safe configuration | Onboarding claim, destination attestation | +| F04 | P0 | Per-user module topology is internally contradictory | Contract design | +| F05 | P0 | RecoveryValidator is not an ordinary Safe module and may bypass ownership | Recovery, EIP-1271, custody | +| F06 | P0 | Router plus selector allowlisting is insufficient | Principal-safety invariant | +| F07 | P0 | CoW cannot preserve the synchronous module invariants behind the same interface | Route replaceability claim | +| F08 | P0 | A permanent IBAN cannot be represented as one terminal Vortex ramp | Backend architecture | +| F09 | P1 | Oracle math and economic bound are underspecified | Contract finalization | +| F10 | P1 | The proposed fee contradicts I1 and changes the compromise bound | Contract finalization | +| F11 | P1 | Passkey-only self-rescue depends on Vortex's RP domain and infrastructure | Liveness claim | +| F12 | P1 | Liquidity caps rely on snapshot TVL rather than executable depth | Availability and rollout | +| F13 | P1 | Webhook, reorg, concurrency, and deposit attribution rules are missing | Backend correctness | +| F14 | P1 | Immutable modules have no credible incident or migration path | Production safety | +| F15 | P1 | Compliance, data protection, and payment-reversal responsibilities are incomplete | Launch readiness | +| F16 | P1 | Dust, gas griefing, and destination edge cases break the automatic-flow promise | Product behavior | +| F17 | P2 | Several failure-mode statements need correction or sharper scope | Specification accuracy | +| F18 | P2 | v1 composes too many overlapping Safe extension mechanisms | Auditability and maintainability | + +## 5. Detailed findings + +### F01 — Monerium control-plane authority may redirect future deposits + +**Priority:** P0 +**Affected PRD sections:** §1, §4, §5.1, §8.3, §10 + +#### Concern + +The custody analysis begins after EURe reaches the expected Safe. It does not establish that Vortex lacks authority to change where future incoming EUR is minted. + +Monerium's published whitelabel documentation states that: + +- a whitelabel client can link addresses to a customer profile; +- `PATCH /ibans/{iban}` can re-associate an existing IBAN with a different linked address or chain; +- incoming payments are routed to the newly associated address; +- a SEPA memo can route issuance to another linked address. + +From the public API shape, a compromised Vortex backend appears able to link an attacker-controlled address — for which the attacker can provide a valid proof of ownership — and move the customer's IBAN to it. This is an inference that must be confirmed with Monerium, but it invalidates the current absolute claim unless Monerium applies additional scopes or user-authorization checks not described publicly. + +#### Required resolution + +Obtain a written and sandbox-verified Monerium guarantee that, for these profiles: + +1. Vortex cannot move the IBAN after activation without authorization from the currently linked Safe; +2. a new address cannot become the default mint address without equivalent end-user authorization; +3. memo-based routing can be disabled or constrained; +4. whitelabel credentials are scoped so their compromise cannot redirect customer issuance; +5. every attempted association change produces an independently monitored event. + +If Monerium cannot provide those controls, rewrite the trust model: + +> Vortex is trusted not to change the Monerium IBAN association. The on-chain non-custody guarantee starts only after EURe is finalized at the verified Safe. + +#### Questions for the author + +- What exact authorization does Monerium require for `PATCH /ibans/{iban}`? +- Can Vortex link its own address to a customer's profile using Vortex-controlled proof of that address? +- Can profiles be configured with exactly one non-movable Ethereum address? +- Can memo routing be disabled? +- How are legacy profiles with existing linked addresses handled? + +### F02 — The “full Vortex compromise” bound is not a full-compromise bound + +**Priority:** P0 +**Affected PRD sections:** §4, §8.2, §8.3, §14 + +#### Concern + +The §8.3 analysis covers a compromised keeper and registry multisig. A full Vortex compromise also includes: + +- Monerium client credentials; +- factory bytecode and deployment calldata; +- frontend-provided configuration; +- Safe singleton and proxy-factory selection; +- Safe owners and threshold; +- enabled modules, guard, module guard, and fallback handler; +- passkey signer coordinates and verifier configuration; +- destination, fee recipient, recovery IBAN, and recovery policy. + +A Safe can contain the user's passkey owner and still contain an attacker owner or unrestricted module. The fixed Monerium ownership message can validate through the user's owner without proving that the remainder of the Safe configuration is safe. + +#### Required resolution + +Rename §8.3 to **“Post-deployment keeper and route-governance compromise”** and add separate threat bounds for: + +1. provisioning compromise; +2. frontend compromise; +3. Monerium credential compromise; +4. oracle compromise; +5. passkey compromise; +6. stablecoin issuer compromise; +7. dependency upgrade or code-substitution compromise. + +Before address linking, both frontend and backend should independently verify the deployed account against a versioned public manifest containing: + +- chain ID; +- exact Safe singleton runtime hash and version; +- canonical proxy factory; +- owners and threshold; +- enabled modules; +- fallback handler; +- transaction guard and module guard; +- module runtime hash and immutable arguments; +- destination; +- fee configuration; +- oracle and registry addresses; +- recovery configuration. + +#### Questions for the author + +- Which components are assumed honest during provisioning? +- What prevents a compromised frontend and backend from presenting a Safe with an extra module? +- What independent evidence can a user or external auditor use to verify a specific deployed account? + +### F03 — The one Monerium signature does not bind the destination or configuration + +**Priority:** P0 +**Affected PRD sections:** §3.1, §5.1, §10 Q2 + +#### Concern + +The fixed message `I hereby declare that I am the address owner.` contains no: + +- chain ID; +- Safe address in the signed text; +- destination; +- module implementation or runtime hash; +- fee rule; +- router/oracle configuration; +- recovery policy. + +The assertion proves control of the configured Safe owner under the Safe's current EIP-1271 behavior. It does not “implicitly ratify” an off-chain destination shown in the UI. + +Requiring cryptographic ownership of an arbitrary external destination creates a second conflict: ownership must be proven by the destination's key, which cannot generally be proven by the Safe passkey and may require a wallet extension or separate signer. + +#### Required resolution + +Choose one explicit model: + +1. **Two-signature model:** retain the Monerium link assertion and add an EIP-712 deployment-intent signature covering the complete manifest. Add a separate destination signature if destination ownership is required. +2. **Safe-as-destination model:** retain USDC in the passkey-owned Safe and remove arbitrary destination attestation and forwarding. +3. **Trusted provisioning model:** keep one signature but state that the user trusts Vortex's frontend and provisioning backend to deploy the displayed configuration. +4. **Custom signer model:** create and audit a custom WebAuthn signer whose validation of the fixed Monerium hash also commits to immutable account configuration. This adds substantial bespoke cryptographic risk and is not recommended for v1. + +#### Questions for the author + +- Is “one signature” a hard product constraint or a preference? +- What constitutes destination ownership for exchanges, smart accounts, and custodial deposit addresses? +- Is a UI confirmation intended as legal consent, cryptographic consent, or both? + +### F04 — The module topology is internally contradictory + +**Priority:** P0 +**Affected PRD sections:** §5.1, §6.3, §8.1, I8 + +#### Concern + +The PRD describes `VortexSwapModule` as: + +- an immutable per-deployment singleton; +- parameterized per Safe; +- containing per-user immutable destination/configuration; +- allowing the user to change destination. + +A single Solidity deployment cannot have different constructor immutables per Safe. A contract-level immutable destination also cannot be changed. If configuration is stored in a mapping, it is storage, not immutable, and requires an initialization/update authorization design. + +#### Required resolution + +Specify one topology in pseudocode and storage layout: + +**Option A — per-Safe minimal clone with immutable arguments** + +- one module instance per Safe; +- destination, oracle, maximum slippage, registry, and fee configuration encoded as immutable arguments; +- changing destination means deploying and owner-authorizing a new module, then disabling the old one. + +**Option B — singleton with Safe-keyed configuration** + +- configuration initialized atomically during Safe setup; +- initialization cannot be front-run or repeated; +- updates are accepted only when the corresponding Safe itself calls through an owner-authorized transaction; +- events and explicit versioning cover every mutation. + +Pin an exact audited Safe version and deployed addresses. `v1.4.1+` is not a reproducible security dependency. + +#### Questions for the author + +- Is the module one deployment per user or one deployment for all users? +- Where exactly is destination stored? +- What on-chain operation implements “change destination”? +- Does the custom `VortexAccountFactory` add value beyond canonical Safe factories sufficient to justify its audit surface? + +### F05 — The recovery validator is not an ordinary Safe module + +**Priority:** P0 +**Affected PRD sections:** §5.3, §6.3, §8.4, Q3, Q4 + +#### Concern + +Enabling a normal Safe module does not change Safe's `isValidSignature`. ERC-1271 support is normally provided by a fallback handler. The design also needs ERC-4337, whose `Safe4337Module` acts as both module and fallback handler. The future CoW proposal expects an extensible fallback-handler path. These cannot be installed independently without an explicit composition design. + +Further issues: + +1. ERC-1271 receives a message hash and signature. It cannot parse the original redeem string unless that string is encoded into the signature payload and re-hashed on-chain. +2. Monerium documents both full and shortened IBAN forms. The policy needs one canonical representation. +3. Monerium accepts timestamps close to the present or in the future; the contract must impose its own narrow validity window. +4. “Whitelisting the link-message shape” is dangerous. If this means returning valid without a genuine passkey owner signature, anyone could satisfy Monerium's ownership check for the Safe. +5. A Vortex-triggerable redemption, even to a pinned bank account, gives Vortex power to dispose of user assets and changes the legal custody analysis. +6. A refund IBAN can be closed, reassigned, or cease belonging to the user. +7. Replay protection needs more than timestamp parsing: chain, Safe, Monerium profile, order identity, exact amount, currency, canonical IBAN, and prior-use state must be bound. + +#### Required resolution + +The recommended v1 resolution is to remove automatic EIP-1271 redemption and use: + +- the user's passkey owner; plus +- an independent, user-controlled recovery owner; +- an owner-authorized Monerium redeem order when recovery is required. + +If automated redemption remains, write a separate protocol specification covering: + +- the exact fallback-handler composition; +- signature byte schema; +- message reconstruction and hash equality; +- canonical amount, timestamp, currency, and IBAN encoding; +- replay state; +- caller independence; +- passkey validation for the address-link message; +- interaction with ERC-4337 and CoW domains; +- legal treatment of Vortex's unilateral redemption authority. + +#### Questions for the author + +- Is `RecoveryValidatorModule` intended to be a module, fallback handler, owner, or ERC-7579 validator? +- How does it obtain the original message bytes from the ERC-1271 call? +- Who or what produces the `signature` bytes for a policy-authorized redemption? +- Does the link message still require a genuine passkey assertion? + +### F06 — Router address plus selector allowlisting is insufficient + +**Priority:** P0 +**Affected PRD sections:** §5.2, §7.4, I1, I2, I3, I7, I9 + +#### Concern + +A selector does not constrain the semantic fields inside calldata. Uniswap `exactInput`, for example, still contains: + +- arbitrary token path; +- recipient; +- amount in; +- minimum output; +- deadline. + +Aggregator entry points can contain arbitrary nested calls. A malicious router may also exploit unrelated token or Permit2 allowances previously created by the Safe owner. Post-conditions over EURe and USDC do not prove that no other approved asset was taken. + +The current I1 statement is therefore stronger than what the described implementation enforces. + +#### Required resolution + +For each supported route, use an immutable, router-specific adapter or have the module construct calldata internally. Enforce: + +- exact router address; +- `value == 0`; +- `operation == CALL`; +- exact token path `EURe V2 → EURC → USDC`; +- exact `amountIn`; +- recipient equal to the Safe during an atomic swap-and-forward flow; +- router-level `amountOutMinimum >= module minOut`; +- a short deadline or execution-validity rule; +- successful return from every `execTransactionFromModule` call; +- safe ERC-20 handling for missing/false return values; +- exact allowance reset; +- expected balance changes and final transfer success. + +Scope the guarantee to EURe and USDC in a dedicated Safe. State explicitly that unrelated assets or approvals introduced by the user are outside the guarantee unless independently protected. + +Governance should allow **immediate route revocation** but require delay for additions or authority expansion. Waiting seven days to remove a compromised router is unacceptable. + +#### Questions for the author + +- Will route calldata be constructed by the module, decoded by the module, or trusted from the keeper? +- How are aggregators with arbitrary executor calldata intended to satisfy I1? +- Is the Safe contractually/product-restricted to this flow only? + +### F07 — CoW is not replaceable behind the synchronous router interface + +**Priority:** P0 +**Affected PRD sections:** §7.4, §8.2 + +#### Concern + +ComposableCoW is a persistent order-authorization system followed by asynchronous settlement. It uses EIP-1271 through an extensible fallback handler and introduces a different lifecycle: + +- conditional-order authorization; +- watchtower discovery; +- order generation; +- solver execution; +- settlement-time signature verification; +- allowance and receiver constraints; +- expiry, cancellation, replay, and possibly partial fills. + +It does not execute inside one `swapAndForward` call where the module can synchronously snapshot balances, call a router, reset approval, verify deltas, and forward output. + +#### Required resolution + +Remove CoW from the claim that all future routes fit the same interface and invariants. Treat it as a separate v2 architecture with its own threat model and fallback-handler composition. + +That v2 specification must cover: + +- exact sell token and maximum amount; +- exact buy token and receiver; +- oracle-derived minimum buy amount; +- order validity and replay domain; +- partial fills; +- cancellation and migration; +- settlement allowance; +- fallback-handler coexistence with ERC-4337 and recovery; +- output attribution to fiat deposits. + +#### Questions for the author + +- Is CoW intended as a synchronous router, a standing order, or a recurring conditional order? +- How would the current post-swap balance-delta invariant be preserved across asynchronous settlement? + +### F08 — A permanent IBAN is not a one-shot Vortex ramp + +**Priority:** P0 +**Affected PRD sections:** §5.2, §6.1, §11 + +#### Concern + +The proposed IBAN can receive deposits repeatedly for years. Vortex's existing ramp model represents one quote and one transaction that recursively reaches a terminal `complete` or `failed` phase. + +Reusing that state machine creates unresolved questions: + +- What reopens a completed ramp? +- How are two rapid deposits represented? +- Which output belongs to which bank deposit if balances are batched? +- How are duplicate Monerium webhooks handled? +- What is the status of the account when one deposit succeeds and another is held for compliance? +- How are historical fees and execution rates represented? + +The current phase processor also documents a non-atomic multi-instance lock and retry-exhaustion gap. A persistent, repeatedly funded Safe should not rely on those semantics without database-enforced serialization. + +#### Required resolution + +Introduce separate persistent models: + +```text +MoneriumAccount + profileId + iban + safeAddress + currentConfigVersion + onboarding/compliance/account status + +FiatDeposit + moneriumOrderId + amount/currency + payment status + mint tx hash + log index + block + compliance status + +ConversionExecution + safeAddress + included deposit IDs + EURe input + USDC gross output + fee + USDC net output + destination + execution tx/status/error +``` + +Use unique Monerium order IDs and `(chainId, txHash, logIndex)` as idempotency keys. Serialize execution by Safe using an atomic database lock or queue. If deposits are batched, define an auditable allocation and rounding rule. + +#### Questions for the author + +- Is the Vortex public API expected to expose one ramp forever or one transaction per deposit? +- Are deposits converted independently or batched? +- What webhook/status object does a partner subscribe to for recurring deposits? + +### F09 — Oracle math and the economic bound are underspecified + +**Priority:** P1 +**Affected PRD sections:** §7.2, §8.3, §11 + +#### Concern + +The formula mixes human-readable and raw token/feed units. For raw values with: + +- EURe: 18 decimals; +- Chainlink EUR/USD: expected 8 decimals, but must be queried; +- USDC: 6 decimals; + +the scale denominator is `10^(18 + feedDecimals - 6)`, which is `10^20` for an 8-decimal feed. The written `10^(-12)` is understandable only if the rate is already a unitless human value, which Solidity will not receive. + +The bound also assumes one USDC equals one USD. EUR/USD does not directly price either EURe market basis or USDC/USD basis. Chainlink deviation threshold is not a complete bound on either stablecoin. + +FX feeds also have market-hours/weekend behavior while the AMM is continuously tradable. + +#### Required resolution + +Specify executable pseudocode including: + +- reading `decimals()`; +- `answer > 0`; +- `updatedAt != 0`; +- maximum age and weekend policy; +- round validity checks appropriate to the chosen feed contract; +- `mulDiv` ordering and overflow safety; +- whether minimum output rounds down; +- use or rejection of a USDC/USD feed; +- behavior during EURe or USDC depeg; +- a maximum oracle age that is an immutable safety ceiling, even if an operational value can be lowered. + +Rewrite the loss statement as: + +> The swap cannot deliver less than the configured percentage of the accepted oracle-model value, assuming the oracle is honest and both token contracts behave as modeled. + +Do not call that a bound on principal under oracle or stablecoin failure. + +#### Questions for the author + +- Is USDC/USD explicitly assumed to be 1.0? +- What happens from Friday FX close through Monday updates? +- What exact proxy address, feed decimals, heartbeat, and failure policy will be pinned? + +### F10 — The fee proposal contradicts the invariants + +**Priority:** P1 +**Affected PRD sections:** §3.3, I1, §8.3, §9, Q1 + +#### Concern + +I1 says USDC can move only to the user's destination. §9 proposes sending an in-module fee to a Vortex treasury. That adds another permitted recipient and changes: + +- custody language; +- user net-output calculation; +- minimum-output calculation; +- balance-delta post-conditions; +- worst-case Vortex extraction; +- event and accounting requirements. + +The module cannot be finalized while fee behavior remains open. + +#### Required resolution + +Choose before contract design: + +1. zero fee/loss-leader for v1; +2. off-chain subscription or billing; +3. immutable on-chain `feeBps`, immutable treasury, immutable maximum fee, and exact calculation from swap output. + +If option 3 is chosen, update I1 and §8.3 and distinguish the disclosed fee from slippage and malicious extraction. + +#### Questions for the author + +- Is the fee assessed per deposit, per batch, or per execution? +- Who pays when multiple small deposits are batched? +- Can the fee or treasury ever change, and under whose authorization? + +### F11 — Passkey-only self-rescue depends on Vortex's domain and services + +**Priority:** P1 +**Affected PRD sections:** §5.3, §11, Q3 + +#### Concern + +WebAuthn credentials are scoped to a relying-party ID. A community recovery site on an unrelated domain cannot invoke a passkey registered for Vortex's RP ID. + +Self-rescue also requires: + +- access to the relevant RP domain/origin; +- a recoverable or discoverable credential; +- credential metadata where needed; +- a transaction builder that knows the Safe/passkey signature format; +- an RPC provider; +- ETH or a Vortex-independent bundler/paymaster/relayer. + +Cloud synchronization is not guaranteed for every credential, device, policy, or authenticator. Safe's own guidance recommends combining passkeys with other authentication methods. + +#### Required resolution + +Make an independent, user-controlled recovery owner mandatory for v1, such as a second passkey under an independent RP, a hardware wallet, or a carefully audited user-controlled recovery scheme. + +Publish and test a disaster-recovery package that can: + +- reconstruct the account from public chain data; +- invoke the user's credential under the correct RP ID; +- build a direct Safe transaction without Vortex's API; +- fund gas without Vortex's paymaster; +- disable the module, change destination, or redeem EURe. + +Document domain-control continuity if Vortex ceases operation. + +#### Questions for the author + +- What RP ID will be used? +- Are credentials required to be discoverable and backup-eligible? +- How does recovery work if Vortex loses its domain or backend database? +- Why is recovery optional if permanent self-custody is a core claim? + +### F12 — The liquidity cap is based on a snapshot, not a durable bound + +**Priority:** P1 +**Affected PRD sections:** §2.1, §7.3, §7.4, §12 + +#### Concern + +Pool TVL is not equivalent to executable depth within 100 bps. Concentrated liquidity can move out of range, providers can withdraw, and routing can change between measurements. + +“Successive executions spaced over time” is keeper policy, not an on-chain invariant. A compromised keeper or permissionless caller can submit multiple cap-sized transactions in rapid succession. Even honest executions may not recover liquidity merely because time passed. + +The quoted 10k and 50k results are also not reproducible without block numbers, quote parameters, gas/fee treatment, route, quote expiry, and provider response. + +#### Required resolution + +- Record a reproducible liquidity-assessment methodology and block number. +- Treat `minOut` as the safety condition and the size cap as an availability parameter. +- Monitor executable on-chain quotes and active liquidity continuously. +- Define launch and pause thresholds. +- If pacing is a requirement, enforce an aggregate per-Safe or global rate limit on-chain; otherwise describe pacing as best-effort only. +- Do not raise caps solely from TVL. + +#### Questions for the author + +- What exact evidence produced “~zero impact” and “~3.6% impact”? +- Is the cap intended to protect price, liveness, platform exposure, or all three? +- What automatically pauses execution when the pool migrates or liquidity disappears? + +### F13 — Webhook, reorg, concurrency, and attribution rules are incomplete + +**Priority:** P1 +**Affected PRD sections:** §5.2, §6.1, §11, §14.9 + +#### Concern + +“Webhook plus on-chain watcher” is a trigger strategy, not an idempotency or reconciliation design. + +Missing requirements include: + +- Monerium HMAC verification over raw request bytes; +- timestamp/replay-window validation; +- constant-time signature comparison; +- persisted webhook-ID deduplication; +- returning `200` promptly and processing asynchronously; +- event ordering and out-of-order updates; +- canonical-chain confirmation policy; +- reorg reconciliation using block hash and log identity; +- unique keeper nonce management across instances; +- serialization of concurrent executions for one Safe; +- stale private-relay transaction replacement; +- allocation of one batched output across several fiat deposits; +- reconciliation between Monerium order amount and actual minted amount; +- notification correction if an event is later reorged or reversed operationally. + +#### Required resolution + +Add an operational state and idempotency specification. On-chain balance should be the execution safety source, while Monerium issue-order IDs should be the accounting identity source. + +Use a database-enforced per-Safe execution lock. Contract-level reentrancy protection does not serialize separate transactions from multiple keeper processes. + +#### Questions for the author + +- How many confirmations are required before conversion and before notification? +- Can two deposits be intentionally combined into one swap? +- How are fees and output allocated when that happens? +- What prevents two keeper instances from racing the same Safe balance? + +### F14 — Immutable modules need an incident and migration path + +**Priority:** P1 +**Affected PRD sections:** §3.3, §7.4, I5, §11, §12 + +#### Concern + +Immutability prevents Vortex from introducing a malicious upgrade, but it also prevents patching a discovered module bug. Users with lost passkeys may continue receiving future deposits into an account whose automation is disabled or vulnerable. + +The registry can halt routes but cannot repair module logic. Moving an IBAN to a new Safe reintroduces F01 and requires an explicit user-authorization model. + +#### Required resolution + +Define before launch: + +- immediate route-disable authority; +- how Monerium deposits are paused or rejected during an incident; +- how users are notified before sending more EUR; +- module version discovery; +- owner-authorized migration to a new module/Safe; +- whether the existing IBAN can move only with the old Safe's signature; +- treatment of users who lost all recovery methods; +- sunset and support duration for old versions. + +#### Questions for the author + +- What happens after a critical module vulnerability is discovered at 02:00 UTC? +- Can Monerium suspend an individual IBAN immediately? +- How is a safe migration reconciled with the “one signature ever” promise? + +### F15 — Compliance and data-protection architecture is incomplete + +**Priority:** P1 +**Affected PRD sections:** §5.1, §6.1, §10, Q6, Q7 + +#### Concern + +The whitelabel flow makes Vortex responsible for handling or orchestrating sensitive identity, banking, and wallet data. The PRD does not define: + +- personal-only versus corporate scope; +- KYC sharing versus KYC reliance prerequisites; +- controller/processor roles and DPA terms; +- retention and deletion rules; +- encryption and access control for IBAN/profile data; +- log redaction and support tooling; +- sanctions and destination screening; +- periodic re-screening of a static destination; +- profile suspension, re-KYC, or closure behavior; +- SEPA recall/fraud claim allocation after EURe has been swapped and forwarded; +- legacy profile migration consent; +- source-of-funds and expected-volume monitoring; +- travel-rule and third-party destination consequences. + +#### Required resolution + +For v1, strongly consider personal, newly onboarded users only. Add a data-flow diagram and retention/access table. Obtain explicit MSA answers for recalls, fraud, compliance holds, profile suspension, IBAN changes, and losses after immediate conversion. + +Do not present the legal non-custody or MiCA conclusion as established until counsel has assessed both on-chain module authority and Monerium control-plane authority. + +#### Questions for the author + +- Is corporate onboarding genuinely in v1 scope? +- Who bears loss if a SEPA transfer is later recalled or alleged fraudulent? +- Can Vortex continue converting while a profile is under review or suspended? +- Who screens and re-screens the destination? + +### F16 — Dust, gas griefing, and destination edge cases break the automatic promise + +**Priority:** P1 +**Affected PRD sections:** §3, §5.1, §5.2, §7.3, §9, §11 + +#### Concern + +The headline says any EUR wired to the IBAN is automatically converted and forwarded. The €25 minimum means a smaller transfer may remain indefinitely as EURe. Mainnet gas can also make €25 uneconomical. + +A user can create recurring keeper costs by sending many small transfers. Vortex cannot prevent transfers to an already issued IBAN, so API-side rate limiting is insufficient. + +Destination risks include: + +- zero/burn/token/router/module addresses; +- wrong chain despite a valid EVM address; +- a contract with no method to recover received ERC-20s; +- exchange minimum-deposit thresholds; +- exchange address rotation or closure; +- destination sanctions/blacklisting after onboarding; +- destination equal to the Safe, where forwarding is redundant; +- loss of access to the destination despite continued automatic transfers. + +#### Required resolution + +- State a minimum deposit and maximum processing delay in user-facing terms. +- Define whether dust is refunded, accumulated, or held indefinitely. +- Make the operational threshold responsive to gas while retaining an immutable safety ceiling/floor where necessary. +- Batch deposits deliberately and disclose batching latency. +- Define destination validation and denylist rules. +- Warn that Vortex cannot prove recoverability of arbitrary contracts or exchange deposits. +- Monitor destination blacklist/sanctions state and define what happens to future deposits. + +#### Questions for the author + +- Who pays gas when fees are below execution cost? +- What happens to a €1 deposit if no further transfer arrives? +- What happens if the destination exchange raises its minimum deposit above the user's normal amount? + +### F17 — Failure-mode statements need correction and sharper scope + +**Priority:** P2 +**Affected PRD sections:** §5.2, §8.5, §11 + +#### Concern + +Several statements should be corrected or qualified: + +1. If swap and USDC forwarding are one atomic transaction, a Circle-blacklisted destination should make the final transfer revert and roll back the swap. Newly acquired USDC should not remain in the Safe from that execution. +2. “Vortex can censor, not execute” is inaccurate if Vortex controls a module capable of causing swap and transfer. The intended distinction is that Vortex can execute only the constrained policy. +3. “Cannot withhold” conflicts with acknowledged keeper censorship, registry route removal, and possible Monerium control-plane changes. Prefer “cannot redirect already-minted assets outside the enumerated policy under stated assumptions.” +4. “Maximum extractable value is 1.15%” is relative to the oracle model, excludes fees, stablecoin basis, provisioning, Monerium authority, other Safe assets/allowances, and oracle failure. +5. Passkeys may be synced; they are not universally cloud-synced by default. +6. Mainnet EIP-7951 is already live as of this PRD date; the spike should benchmark the exact chosen Safe/passkey implementation rather than treat post-Fusaka support as hypothetical. + +#### Required resolution + +Replace absolute language with a matrix of guarantees and assumptions. Every user-facing security claim should specify: + +- asset and lifecycle stage; +- trusted dependencies; +- authorized Vortex actions; +- excluded compromise classes; +- liveness versus theft protection; +- recovery requirements. + +### F18 — v1 combines too many overlapping extension mechanisms + +**Priority:** P2 +**Affected PRD sections:** §6, §7.4, §8.4, §12 + +#### Concern + +The proposed v1/v2 path requires engineers and auditors to reason simultaneously about: + +- Safe owner signatures; +- WebAuthn encoding and P-256 verification; +- ERC-4337 module/fallback behavior; +- a custom swap module; +- a router registry and timelock; +- a custom recovery EIP-1271 policy; +- potentially an extensible fallback handler; +- potentially ComposableCoW; +- a custom account factory; +- a persistent off-chain state machine. + +Much of the underlying problem is intrinsically complex, but the proposed expression adds avoidable cross-product complexity. In particular, recovery, ERC-4337, and CoW all touch Safe extension/fallback behavior, while the generic registry attempts to make materially different execution models look interchangeable. + +#### Required resolution + +Apply design sacrifice to v1: + +- one exact Safe deployment path; +- one passkey owner plus one independent recovery owner; +- one per-Safe swap module topology; +- one hard-coded Uniswap adapter; +- no generic aggregator calldata; +- no CoW; +- no automatic redeem validator; +- no mutable fee until finalized; +- one persistent-account/deposit/execution data model. + +Add complexity only after a concrete operational need and a new threat model justify it. + +## 6. Proposed reduced v1 architecture + +This is a strawman for discussion, not a final specification. + +### 6.1 Scope + +- Personal profiles only. +- Newly onboarded users only; legacy migration deferred. +- Ethereum mainnet only. +- EURe V2 → EURC → USDC through one Uniswap v3 route. +- One destination configured at onboarding. +- Explicit minimum deposit and processing SLA. +- No automated offramp/redeem. +- No CoW or generic aggregators. + +### 6.2 Monerium prerequisite + +Do not proceed unless Monerium provides an enforceable mechanism under which the IBAN association cannot be moved and no alternative mint destination can be added or selected without authorization from the currently linked Safe. + +If that is unavailable, preserve the product but change the security claim to acknowledge Vortex trust before mint. + +### 6.3 Safe + +- Pin exact canonical Safe contracts and runtime hashes. +- Passkey signer as one owner. +- Independent user-controlled recovery owner from launch. +- Threshold/recovery structure explicitly documented. +- Canonical Safe deployment components preferred over a custom factory unless atomic custom deployment is demonstrably necessary. +- Publish a configuration manifest and verifier. + +### 6.4 Swap policy + +- One per-Safe immutable module clone. +- Module constructs Uniswap calldata internally. +- Permissionless trigger if timing-grief analysis accepts it; otherwise a narrowly authorized keeper with a liveness fallback. +- `CALL` only, `value == 0`. +- Exact EURe approval, exact path, Safe recipient, module-derived `minOut`, short deadline. +- Atomic swap, approval reset, output check, fee calculation if any, and final transfer. +- Immediate route disable; no new route types in v1. + +### 6.5 Pricing + +- Exact Chainlink feed address and decimal handling. +- Explicit USDC/USD assumption or second feed. +- Immutable maximum staleness and maximum slippage. +- Weekend policy. +- Reproducible rounding and overflow behavior. + +### 6.6 Backend + +- Persistent `MoneriumAccount`. +- One `FiatDeposit` per Monerium issue order. +- One `ConversionExecution` per swap or intentional batch. +- Atomic per-Safe job serialization. +- HMAC-verified, deduplicated Monerium webhooks. +- On-chain watcher as reconciliation and trigger backup. +- Explicit reorg/finality policy. + +### 6.7 Recovery and migration + +- User or independent recovery owner signs any Safe exit or Monerium redeem order. +- No Vortex-only EIP-1271 redemption bypass. +- Public, tested disaster-recovery tooling. +- Immediate account/route pause and user notification during incidents. +- Owner-authorized module or account migration. + +### 6.8 Fees + +Prefer zero fee or off-chain billing for the first pilot. If an on-chain fee is required, finalize it before audit and include its immutable recipient and maximum in the signed configuration and contract invariants. + +## 7. Required acceptance gates + +The design should not advance from architecture review until all of the following are complete: + +- [ ] Monerium confirms IBAN/address reassociation authorization and memo-routing controls in writing and sandbox. +- [ ] The trust model is split by lifecycle stage and compromise class. +- [ ] One concrete module topology and storage layout is selected. +- [ ] The onboarding signature model honestly addresses configuration and destination consent. +- [ ] Recovery is redesigned or removed from v1. +- [ ] Router calldata is constructed or fully semantically validated on-chain. +- [ ] CoW is removed from the same-interface claim or specified separately. +- [ ] The backend uses persistent accounts plus per-deposit/per-execution records. +- [ ] Oracle pseudocode, decimals, staleness, weekend behavior, and rounding are finalized. +- [ ] Fee behavior is finalized and added to invariants. +- [ ] A Vortex-independent user recovery path is demonstrated. +- [ ] Liquidity measurements are reproducible and continuously monitored. +- [ ] Webhook, reorg, concurrency, and idempotency behavior is specified. +- [ ] Incident pause, module migration, and IBAN migration procedures are documented. +- [ ] Legal, compliance, privacy, sanctions, and payment-reversal responsibilities are signed off. +- [ ] User-facing minimums, timing, rate basis, fees, and failure behavior are drafted. +- [ ] Exact dependency versions, addresses, runtime hashes, and audit artifacts are pinned. + +## 8. Requested author response + +Please respond by copying this table and filling in the final two columns. + +| ID | Disposition (`Accept`, `Reject`, `Modify`, `Needs validation`) | Author response / proposed PRD change | +|---|---|---| +| F01 | | | +| F02 | | | +| F03 | | | +| F04 | | | +| F05 | | | +| F06 | | | +| F07 | | | +| F08 | | | +| F09 | | | +| F10 | | | +| F11 | | | +| F12 | | | +| F13 | | | +| F14 | | | +| F15 | | | +| F16 | | | +| F17 | | | +| F18 | | | + +The most important requested answer is not whether each mechanism can be implemented individually. It is whether the revised composition supports a precise, end-to-end security statement that remains true under every authority Vortex actually holds. + +## 9. Primary references + +- [Monerium Whitelabel documentation](https://docs.monerium.com/whitelabel/) +- [Safe smart-account modules](https://docs.safe.global/advanced/smart-account-modules) +- [Safe fallback handlers](https://docs.safe.global/advanced/smart-account-fallback-handler) +- [Safe and ERC-4337](https://docs.safe.global/advanced/erc-4337/4337-safe) +- [Safe and passkeys](https://docs.safe.global/advanced/passkeys/passkeys-safe) +- [Safe passkey signer guidance](https://docs.safe.global/sdk/signers/passkeys) +- [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) +- [EIP-7951 P-256 precompile](https://eips.ethereum.org/EIPS/eip-7951) +- [Ethereum Fusaka overview](https://ethereum.org/roadmap/fusaka/) +- [Chainlink Ethereum EUR/USD feed](https://data.chain.link/feeds/ethereum/mainnet/eur-usd) +- [ComposableCoW architecture](https://cowswap.mintlify.app/composable-cow/architecture) +- [Vortex state-machine security specification](../security-spec/03-ramp-engine/state-machine.md) +- [Historical Vortex Monerium security specification](../security-spec/05-integrations/monerium.md) diff --git a/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md new file mode 100644 index 000000000..4aaf44788 --- /dev/null +++ b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md @@ -0,0 +1,151 @@ +# B2B Variant: Zero-Touch Onboarding via Attestor-Linked Forwarder + +**Status:** Selected for implementation (2026-07-17). Monerium compliance verbally accepted the pattern (Telegram; conditional on fallback capability, which is now mandatory by design). Written approval pending (G1), legal review pending (G2), adversarial review running in parallel with implementation. +**Date:** 2026-07-14, updated 2026-07-17 +**Related:** [main PRD v2](./monerium-eur-usdc-onramp.md) · [review](./monerium-eur-usdc-onramp-architecture-review.md) · [re-review](./monerium-eur-usdc-onramp-architecture-rereview.md) · [deferred decisions](./monerium-onramp-deferred-decisions.md) · [implementation plan](./monerium-b2b-implementation-plan.md) + +**Update 2026-07-17 — Monerium compliance outcome and scope decisions:** + +- Monerium compliance accepts the attestor pattern **provided fallback capabilities are maintained**, and requires that clients be **explicitly informed that this setup limits their ability to redeem EURe directly with Monerium**. We committed to that disclosure (registry item B6). +- **Tier C is dropped:** a self-custodied `fallbackAddress` is mandatory for every client. Sections below that describe Tier C are retained for historical context only. +- **Issuer recovery backstop confirmed with a catch:** Monerium can burn tokens from a linked address and pay out — only to the customer's own external bank account, currently no fees, re-verification possible. But their recovery flow validates a signature against the linked address, which our constrained `isValidSignature` would reject. Exact recovery-message format needed from their technical team (registry item T1) so the forwarder can whitelist its hash at compile time; if unanswered by deploy, ship without it — the mandatory fallback address carries recovery, and the issuer backstop becomes best-effort. +- **Scope:** B2B variant implemented first (consumer flow is phase 2); backend targets the **whitelabel API** directly, developed against the sandbox during MSA negotiation. + +--- + +## 1. TL;DR (for everyone) + +**What this is.** A variant of our Monerium EUR→USDC onramp aimed at **business clients that come to us through a partner**. Each client gets a personal IBAN. Any euros they wire to it are automatically converted to USDC and delivered to a crypto address they chose upfront. The special part: **the client never has to touch a Vortex app, install a wallet, or digitally sign anything.** Onboarding happens entirely through paperwork (KYB with Monerium plus a contract with us that states their payout address). + +**Why this wasn't possible before.** Connecting an IBAN to an on-chain account normally requires the client to digitally sign one declaration ("I own this address") — that was the single step in our consumer design that forced the client into a UI with a passkey. In this variant, the on-chain account is a small special-purpose program (a "forwarding contract") that **we** deploy for the client, and it is built so that **our own signature can complete that connection** — while the program itself physically cannot do anything except convert the client's euros and deliver them to the client's pre-agreed address. + +**Why we're still not the custodian.** The obvious worry: "if Vortex signs, doesn't Vortex control the money?" No — and this is checkable by anyone reading the contract code on-chain. Our signing power is restricted inside the contract to exactly one sentence: the connection declaration. It cannot approve payouts, withdrawals, or transfers. Nobody — including us — can send the funds anywhere except the client's pre-agreed address (plus the client-controlled failsafes below). Our remaining powers are: run the conversion, pause it, and tune bounded operational parameters. We can delay money; we cannot take or redirect it. + +**The trade-off, honestly.** Because the client holds no key, there is no all-purpose recovery lever if something unexpected breaks (a trading pool dries up, a price feed is retired, the payout address stops working). Every rescue path must be designed in upfront. Our answer (and a condition of Monerium's acceptance): every client **must** name one **fallback address they control** in the paperwork. All emergency flows point there. As a break-glass backstop behind that, Monerium itself can recover tokens from the linked address and pay them out to the client's own verified bank account. Clients are told explicitly (Monerium requires this) that EURe received at the forwarding address cannot be redeemed directly from it — redemption runs via withdrawal to their fallback address, or via Monerium's recovery process as a last resort. + +(Earlier drafts had a "Tier C" for clients refusing any self-controlled address, and a "Tier B" where our partner holds emergency keys. Both are dropped: the partner declines custody-like powers, and Monerium's acceptance is conditioned on fallback capability.) + +**Exchange (CEX) payout addresses are allowed** — the partner requires this. We manage the known risks (exchanges rotate deposit addresses silently) with: a small test transfer before go-live, an automatic pause if an account is dormant too long until the address is re-confirmed, a minimum payout size, and contract terms that put address-validity risk on the client/partner — same way traditional payout providers handle wrong-bank-account risk. One iron rule in all tiers: **we never send raw EURe to an exchange address** (exchanges don't support the token; it would be lost). + +**What must happen before this ships.** (1) **Monerium must approve the pattern in writing** — the ownership declaration would be produced by us, not the client, and doing that without their blessing risks our account; (2) **legal review** — our non-custody argument is strong but "custody" isn't the only licensing question (automatically converting and forwarding for clients may itself be a regulated service under MiCA); (3) partner/client terms for the destination policy; (4) the open engineering findings from the ongoing architecture re-review also apply here. + +--- + +## 2. Context (technical from here on) + +The [main PRD v2](./monerium-eur-usdc-onramp.md) targets consumers: a Safe owned by the user's passkey plus a recovery owner, with a constrained swap module. Its one unavoidable UI moment is the Monerium link signature — the user's passkey signs `"I hereby declare that I am the address owner."`, validated via the Safe's EIP-1271. + +For partner-sourced business clients we want **zero Vortex-side interaction**. KYB happens with Monerium (legacy OAuth app now, whitelabel later — portability is a G1 MSA item; currently confirmed only informally). The destination address arrives via contract paperwork. The only blocker was the link signature. This variant removes it. + +## 3. Mechanism: the attestor-constrained forwarder + +### 3.1 How Monerium's check works + +Monerium validates a link by calling `isValidSignature(bytes32 hash, bytes signature)` (EIP-1271) on the to-be-linked contract — an off-chain `eth_call`, at link time. The message is a **fixed string**, so its EIP-191 hash is a compile-time constant. Redeem orders (`"Send EUR to at "`) are **also** EIP-1271-validated — this fact drives the whole design. + +### 3.2 Why the naive version is unsafe + +"Just make the deployer key the contract's 1271 owner" fails catastrophically: a general-purpose validation key doesn't just validate the link message — it validates **redeem orders too**. Vortex could then sign `"Send EUR to "` and Monerium would burn the EURe and pay out to an arbitrary bank account. Full disposal power: a theft path and unambiguous custody. The hard-coded forwarding logic is irrelevant because redemption bypasses it. + +### 3.3 The constrained version + +`isValidSignature` accepts **exactly one hash** and only from the Vortex attestor key, with the attestation bound to the specific contract: + +```solidity +bytes32 constant LINK_HASH = /* EIP-191 hash of the fixed Monerium link message */; + +function isValidSignature(bytes32 hash, bytes calldata sig) external view returns (bytes4) { + if (hash != LINK_HASH) return 0xffffffff; + // attestor signs keccak256(chainid, address(this), LINK_HASH): no cross-contract + // and no cross-chain replay, + // and no third party can link this contract to a foreign Monerium profile + address signer = ECDSA.recover(keccak256(abi.encodePacked(block.chainid, address(this), LINK_HASH)), sig); + return signer == VORTEX_ATTESTOR ? bytes4(0x1626ba7e) : bytes4(0xffffffff); +} +``` + +Consequences: + +- Vortex can complete the link with no client interaction. +- Vortex **cannot** sign redeem orders or anything else — the attestor key is provably not a "means of access" to the funds. +- Every other hash fails, so no future Monerium message type validates by accident. +- Restricting to the attestor key (rather than accepting the constant hash from anyone) prevents a third party with their own Monerium profile from linking our client's forwarder to *their* profile. + +### 3.4 Contract shape + +No Safe, no passkey, no fallback handler: the account **is** a minimal purpose-built forwarder (per client, or a singleton with per-client config — same Option B analysis as PRD v2 §6.2). It reuses the PRD v2 conversion policy unchanged: pinned EURe→EURC→USDC Uniswap v3 route, contract-constructed calldata, Chainlink EUR/USD `minOut` with staleness ceiling, `CALL` only, exact approvals with reset, atomic delta checks and forwarding, keeper + delayed permissionless trigger, guardian pause. Per-client config: `destination`, `fallbackAddress` (Tier A; zero for Tier C), `feeBps` (immutable post-init), tier flags. + +## 4. Custody and compliance + +| Power | Vortex? | Notes | +|---|---|---| +| Redirect minted funds to any address | **No** | Only `destination` / tiered failsafe targets; immutable logic | +| Sign redeem orders (fiat out) | **No** | 1271 constrained to the link hash | +| Withdraw / sweep to Vortex | **No** | No such function exists | +| Execute conversion, pause, tune bounded params | Yes | Delay-only powers; worst case within slippage bound + disclosed fee | +| Deploy the contract (provisioning) | Yes | S0 trust as in PRD v2; public config manifest still applies (re-review R01 caveat: the manifest is consistency evidence, not an independent trust root) | +| Move the IBAN at Monerium (`PATCH /ibans`) | Yes (credentials) | **Unchanged S1 risk** from PRD v2 — this variant neither worsens nor fixes it; G1 | + +Three non-negotiable caveats: + +1. **Monerium's written approval is required.** The link signature exists so Monerium can verify *the customer* controls the mint address; here the declaration is produced by Vortex about a contract in which the customer holds no key. Undisclosed, this is the "operating on behalf of third parties without prior written approval" pattern their ToS prohibits — heightened on the legacy OAuth app, where our whitelabel-portability assurances are informal. Present the pattern openly (immutable forwarder, on-chain-verifiable constraint, funds only reachable by the client); it is arguably *safer* for Monerium than a user EOA, but it's their call. New G1 item. +2. **Non-custody ≠ out of MiCA scope.** The constrained-attestor argument against custody (Art. 3(1)(17): no control of assets or means of access) is strong, and pause/params are delay-only powers. But *exchange of crypto-assets* and *transfer services on behalf of clients* are separate CASP services — automatic conversion+forwarding for clients may qualify regardless of custody. G2 counsel question; do not present "no custody" as "no license needed." +3. **Provisioning concentration.** With no user verification moment at all, S0 trust in Vortex is *higher* than in the consumer flow. The manifest/transparency machinery matters more here, not less. + +## 5. The recovery problem + +In the consumer design, the client's Safe ownership was a **universal escape hatch**: whatever broke, an owner could move the funds. With zero client keys, only pre-enumerated failures are recoverable; anything unforeseen is permanent. Concrete stuck states: + +| State | Behavior without failsafes | +|---|---| +| Route dies (the EURe/EURC pool is ~$100k TVL — one LP leaving kills it) | Swaps revert forever; EURe accumulates permanently | +| Chainlink retires the EUR/USD feed (routine, weeks of notice) | Staleness check fails forever; permanent | +| EURe depeg beyond slippage bound | Reverts by design; permanent if depeg persists | +| Destination blacklisted by Circle | Atomic revert; EURe accumulates permanently | +| **CEX rotates/closes the deposit address** | **Nothing reverts — funds keep arriving somewhere the client no longer controls. Silent loss; undetectable on-chain** | +| Vortex disappears | Permissionless trigger keeps the happy path alive; combined with any state above → stuck | + +## 6. Failsafe stack and destination policy + +Decision history: Tier B (partner-held recovery role) rejected 2026-07-14 — the partner declines custody-like powers. Tier C (no fallback, stuck-but-safe) dropped 2026-07-17 — Monerium's acceptance is conditioned on maintained fallback capability. **Final policy: exactly one tier; the fallback address is mandatory.** CEX destinations are allowed (partner requirement). + +### Mandatory fallback address (every client) + +One additional paperwork field: a **self-custodied** `fallbackAddress`. It can call `updateDestination`, `sweep(token, to)`, and pause/unpause its own account. Plus an immutable **permissionless dead-man sweep**: anyone may move a stranded EURe balance to `fallbackAddress` after N days unconverted (proposal: 60). Non-custodial: all authority and all emergency targets are the client's. Note a useful quirk: Circle blacklisting blocks USDC transfers, not contract calls or EURe — even a blacklisted fallback can still rotate the destination and sweep EURe out. + +### Redemption disclosure and issuer backstop (Monerium requirements/commitments, 2026-07-16/17) + +- Client terms must state explicitly: *EURe received at the forwarding address cannot be redeemed directly with Monerium from that address; redemption requires withdrawal to your fallback address (from which you can redeem normally), or Monerium's recovery process as a last resort.* (Registry B6.) +- Issuer backstop: Monerium confirmed it can burn tokens from a linked address and pay out **only to the customer's own external bank account**, currently without fees, possibly requiring re-verification. Open item T1: their recovery flow validates a signature against the linked address — the forwarder must whitelist the recovery-message hash (compile-time constant) or the backstop fails-closed for contract addresses. If T1 is unanswered at deploy time, ship without the whitelist; the mandatory fallback carries recovery. + +### Staleness must pause, not lose + +- **Never send raw EURe to a CEX destination.** Iron rule; EURe-recovery targets are the fallback (A) or the contract itself (C). +- **Penny test** at activation: small USDC forward, client/partner confirms exchange credit before the IBAN goes live (also catches exchanges that mis-credit contract-originated token transfers). +- **Dormancy gate:** no successful forward for X days (proposal: 60) ⇒ forwarding pauses until the destination is re-confirmed (partner API ping suffices — still zero client UI). Rotation risk concentrates in dormancy; this converts silent loss into a pause. +- **Minimum forward ≥ the exchange's minimum deposit**; below it, accumulate. +- **Liability allocation in the terms:** client/partner warrants destination validity and bears rotation losses; Vortex commits to the penny test and dormancy gate as diligence. This is how traditional payout processors carry the same risk (a wrong/closed bank account is the instructing party's loss) — contractual allocation is the only mechanism that can actually hold it, since a CEX address's continued validity is not verifiable on-chain. + +Residual loss scenario in Tier A: client loses the fallback key **and** the destination breaks — ordinary self-custody residual. In Tier C: any unforeseen terminal failure — accepted in writing. + +## 7. Differences vs the consumer flow (PRD v2) + +| | Consumer (PRD v2) | B2B variant | +|---|---|---| +| Client interaction | Passkey ceremony + one link signature | None on Vortex side (KYB with Monerium + paperwork) | +| Account | Safe v1.4.1, passkey + recovery owner | Minimal forwarder, no owners | +| Link signature | User passkey via Safe EIP-1271 | Vortex attestor via hash-constrained EIP-1271 | +| Universal recovery | Owners can do anything | None — tiered failsafes only (§6) | +| Destination changes | Owner-signed Safe tx | `fallbackAddress` only | +| Custody posture | User owns account | No one holds spend keys; Vortex delay-only powers; stronger in one way (no opaque owner-signature surface, cf. re-review R08), weaker in another (higher S0 provisioning trust) | +| Conversion policy | Identical (route, oracle, invariants, keeper) | Identical | + +## 8. Open items + +Tracked centrally in the [deferred-decisions registry](./monerium-onramp-deferred-decisions.md); summary: + +1. **G1 written package from Monerium** — verbal acceptance exists (attestor pattern, disclosure requirement, recovery backstop); must be consolidated in writing, together with the pre-existing items: IBAN pinning (`PATCH /ibans`), profile portability, recall liability, corporate-KYB mechanism (T3), and the recovery-message format (T1). +2. **G2 legal:** custody opinion for the constrained-attestor construction; MiCA exchange/transfer-service scoping; disclosure enforceability. +3. **Partner/client terms:** destination warranty, liability allocation, dormancy re-confirmation mechanics, redemption disclosure (B6). +4. **Inherited re-review findings** — dispositions and concrete resolutions live in the [implementation plan](./monerium-b2b-implementation-plan.md): R01 (manifest trust root), R03 (enforceable start time — resolved via on-chain `strandedSince` marker), R04 (sweep vs attribution races), R05 (per-account pause — load-bearing for the dormancy gate), R06 (webhook durability), R09 (unsolicited-token rules), R10 (parameter/role invariants). +5. **Adversarial review of this variant** — runs in parallel with implementation (decision 2026-07-17); must cover the constrained `isValidSignature` (encoding, replay, T1 whitelist interaction) and the fallback mechanics. diff --git a/docs/prd/monerium-eur-usdc-onramp.md b/docs/prd/monerium-eur-usdc-onramp.md new file mode 100644 index 000000000..52e67d2b0 --- /dev/null +++ b/docs/prd/monerium-eur-usdc-onramp.md @@ -0,0 +1,297 @@ +# PRD: Quoteless EUR → USDC (Ethereum) Onramp via Monerium Whitelabel + +**Version:** 2.0 (response to [architecture review](./monerium-eur-usdc-onramp-architecture-review.md); v1 in git history) +**Status:** Revised draft — awaiting re-review and external gates (§13) +**Date:** 2026-07-13 +**Owner:** Vortex team + +**Changes since v1 (for the re-reviewer):** + +- Trust model rewritten as scoped guarantees per lifecycle stage; the absolute "Vortex can never redirect funds" claim is retracted (F01, F02, F17). +- Monerium control-plane authority (`PATCH /ibans/{iban}` on bearer auth alone) verified against live docs and added as launch gate G1 (F01). +- One-signature claim corrected: provisioning is a trusted step, made verifiable via a published configuration manifest; no cryptographic-consent claim (F02, F03). +- Module topology fixed: one immutable singleton with Safe-keyed configuration, atomic initialization, Safe-only mutation (F04). +- Automatic EIP-1271 redeem validator **removed from v1**; recovery redesigned around a mandatory independent recovery owner (F05, F11). +- Keeper-supplied route calldata removed: the module constructs Uniswap calldata internally; generic router registry removed from v1; instant pause vs. timelocked expansion (F06). +- CoW removed from the "same interface" claim; deferred to a separate v2 specification (F07). +- ERC-4337 removed from v1 entirely; rescue path is plain `execTransaction` (F18, F05). +- Backend redesigned around persistent `MoneriumAccount` / `FiatDeposit` / `ConversionExecution` models with idempotency and per-Safe serialization; does not reuse the one-shot ramp state machine (F08, F13). +- Oracle math specified with raw-unit pseudocode, explicit USDC/USD assumption, weekend policy (F09). +- Fee finalized structurally: `feeBps` in per-account config (pilot = 0), immutable `MAX_FEE_BPS` and treasury in the singleton; I1 updated (F10). +- Liquidity caps reframed as availability parameters; `minOut` is the safety condition; reproducible measurement + monitoring required (F12). +- Incident, migration, compliance, dust, and destination-edge sections added (F14, F15, F16). +- Failure-mode corrections applied, incl. atomic-revert behavior on blacklisted destination (F17). +- Filled review response table embedded as Appendix A. + +--- + +## 1. Summary + +Vortex adds a new onramp: a user onboards once, receives a **dedicated virtual IBAN** (issued by Monerium under Vortex's whitelabel integration) linked to a **user-owned Safe** on Ethereum. EUR wired to that IBAN is minted as EURe into the Safe and automatically converted to USDC and forwarded to a **destination address fixed at onboarding** — no per-transfer quote, signature, or interaction. + +**Security posture (honest version):** this design does *not* claim Vortex can never touch user funds. It provides **scoped guarantees per lifecycle stage** (§4): before mint, Vortex and Monerium are trusted parties with defined, monitored, contractually constrained authority; after mint, an immutable on-chain policy limits Vortex's authority to executing a fixed conversion, pausing it, and tuning bounded availability parameters — it cannot redirect minted principal outside the enumerated policy, under the stated assumptions. + +## 2. Scope (reduced v1) + +**In scope:** + +- Personal Monerium profiles only; **newly onboarded users only** (legacy migration deferred). +- Ethereum mainnet only. +- One swap route: EURe V2 → EURC → USDC via one pinned Uniswap v3 router; calldata constructed by the module. +- One destination per account, set at onboarding; changeable only by the Safe's owners. +- Explicit minimum deposit and processing SLA (§10.3). +- Mandatory independent recovery owner (§8). +- Zero on-chain fee for the pilot; fee structure finalized in the contract regardless (§9). + +**Out of scope for v1** (each requires its own future spec): offramp/automated redeem, CoW or any aggregator, ERC-4337, corporate profiles, legacy-user migration, other chains, per-transfer destinations, memo-routing features. + +## 3. Verified external facts + +Re-verify all before build; dates note when checked. + +- **Monerium link message** (2026-07-13): fixed string `"I hereby declare that I am the address owner."`; smart-contract accounts validated via on-chain EIP-1271 `isValidSignature(bytes32,bytes)`; contract must be deployed at validation time (no ERC-6492 documented); linking is per-chain. Redeem orders also accept EIP-1271. ([docs.monerium.com/oauth/#eip-1271](https://docs.monerium.com/oauth/#eip-1271)) +- **Monerium control plane** (2026-07-13, **F01 verified**): `PATCH /ibans/{iban}` — "Move an existing IBAN to a specified address an chain. All incoming EUR payments will automatically be routed to the address on that chain." Authorization: **API client bearer token only**; no signature from the currently linked address. `POST /addresses` requires a signature only from the **new** address's owner. Consequence: whoever holds Vortex's whitelabel credentials can redirect future mints. Memo-based routing was **not** found in current docs (review's sub-claim unconfirmed). ([docs.monerium.com/api](https://docs.monerium.com/api)) +- **Safe passkeys**: WebAuthn credentials as Safe owners via Safe's WebAuthn signer contracts; Ethereum mainnet has the EIP-7951 P-256 precompile since Fusaka (Dec 2025). Exact signer contracts, addresses, and gas to be pinned and benchmarked in spike G0. +- **Liquidity snapshot** (2026-07-10; see methodology caveat §7.4): no meaningful direct EURe/USDC pool on Ethereum. Route: EURe/EURC Uniswap v3 0.05% (`0x2a817bd5018f9782f84398067639230121e07d4c`, ~$104k TVL — bottleneck) → EURC/USDC 0.05% (`0x95dbb3c7546f22bce375900abfdd64a4e5bd73d6`, >$5M TVL). Aggregator quotes: ~spot at 10k EURe; ~3.6% impact at 50k via pure AMM. These are **snapshots, not durable bounds** (F12). +- **EURe V2 (Ethereum)**: `0x39b8B6385416f4cA36a20319F70D28621895279D`. V1 (`0x3231Cb...273f`) is deprecated; several stale V1 pools still show TVL and must be excluded from routing and tests. + +## 4. Trust model — scoped guarantees by lifecycle stage + +Replaces v1 §4/§8.3. Every user-facing security claim must be traceable to one row. + +| Stage | Guarantee | Trusted dependencies | Vortex's authority | Excluded / residual | +|---|---|---|---|---| +| **S0 Provisioning** (onboarding) | Deployed account configuration is **verifiable** against a published manifest before first deposit; fraud is detectable, not cryptographically prevented | Vortex frontend + backend + deployment path at time of onboarding; Safe & signer contracts as audited | Full (Vortex constructs the account) | A compromised provisioning pipeline can deploy a hostile account. Mitigation: manifest + independent verifier (§6.4); no cryptographic consent claim is made (F03) | +| **S1 Fiat ingress** (bank → Monerium → mint) | Deposits mint to the linked Safe **while the IBAN association is unchanged**; association changes are monitored and alarmed | Monerium (regulated EMI); **Vortex's Monerium API credentials** (F01) | Can re-associate the IBAN via `PATCH /ibans` (bearer token only) → redirect *future* mints, absent Monerium-side controls (gate G1) | Monerium insolvency/compliance action; credential theft. Mitigations: G1 contractual/technical pinning, credential isolation (HSM/scoped tokens if available), continuous association monitoring + user alert + pause | +| **S2 On-chain conversion** (EURe in Safe → USDC) | Under assumptions A1–A4 (below): minted assets cannot leave the Safe except (a) into the fixed swap returning ≥ `minOut` USDC to the Safe, (b) USDC to `destination`, (c) fee ≤ `feeBps` (pilot 0) to the immutable treasury. Max adverse extraction per swap relative to the oracle model = slippage margin + configured fee | Chainlink EUR/USD (A1); EURe/EURC/USDC token contracts behave as modeled, incl. issuer powers (A2); audited Safe + module code (A3); USDC/USD ≈ 1 within the slippage margin (A4) | Execute the fixed policy; pause instantly; tune availability params within immutable bounds (§7.3); nothing else | Oracle compromise, stablecoin depeg beyond margin, token-issuer freeze/blacklist, undiscovered contract bugs. Not covered: unrelated assets/approvals the user adds to the Safe (§7.2) | +| **S3 Delivery** | USDC reaches `destination` exactly as forwarded | Destination remains valid, non-blacklisted, and accessible to the user | None (cannot change destination) | Destination attestation is legal, not cryptographic (§6.3); exchange address rotation, blacklisting (§10.4) | +| **S4 Recovery / exit** | The user can always exit with assets using their owners (passkey and/or recovery owner) without Vortex's API, given public tooling + any funded relayer | User retains ≥1 owner credential; Ethereum RPC access | None (cannot block `execTransaction`) | Passkey RP-ID depends on Vortex's domain (§8.2); loss of **all** owner credentials strands user-initiated actions (automation continues) | + +**Liveness vs. safety:** Vortex can always *fail to act* (keeper down, pause engaged, Monerium relationship terminated). Liveness failures leave funds as EURe in the user's Safe (S2) or as unminted fiat claims at Monerium (S1); they do not move assets. + +## 5. End-to-end flows + +### 5.1 Onboarding + +1. Vortex-branded KYC (personal profiles only), submitted to Monerium via whitelabel API; approval via webhook. +2. **Passkey creation.** RP ID = Vortex's apex domain (pinned in docs); credential required to be discoverable and backup-eligible (enforced via WebAuthn `residentKey: required`, attestation-checked where possible). Documented explicitly: sync is typical, not guaranteed (F17.5). +3. **Recovery owner setup (mandatory, F11).** User chooses: second passkey on another device, an existing EOA/hardware wallet, or a **printable one-time recovery key** (EOA generated client-side, shown once, never stored by Vortex). Safe owners = [WebAuthnSigner, recoveryOwner], threshold 1. +4. **Destination collection.** Validation per §10.4; user attests ownership of the destination (checkbox + legal language — this is *legal consent*, not cryptographic proof; F03). +5. **Atomic deployment** via canonical Safe components (§6.1): proxy factory → Safe setup with a minimal audited setup library that enables `VortexSwapModule` and calls `initialize(destination, feeBps)` in the same transaction. Vortex pays gas. +6. **Manifest publication + verification (§6.4).** Onboarding halts unless the independent verifier confirms the deployed account matches the manifest. +7. **The Monerium link signature**: passkey signs the fixed link message; validated via the Safe's EIP-1271 (CompatibilityFallbackHandler → WebAuthn signer). This is the single signature the *flow* requires; the recovery setup may involve its own ceremony. "One signature" is a UX goal for the Monerium step, not a security claim (F03). +8. Vortex calls `POST /addresses` (chain: ethereum); Monerium issues the IBAN. +9. **Disclosure screen**: fee rule, rate basis (Chainlink EUR/USD ± slippage bound), minimum deposit, processing SLA, weekend behavior, failure behavior, S0–S4 trust summary in plain language. + +### 5.2 Steady-state deposit + +1. User wires EUR (SEPA / SEPA Instant) to their IBAN. Monerium mints EURe (V2) to the Safe. +2. Backend ingests the Monerium webhook (HMAC-verified, deduplicated; §11.2) and/or the on-chain Transfer watcher; records a `FiatDeposit`. +3. Keeper calls `swapAndForward(safe)` (§7.1) under a per-Safe database lock; submits via private orderflow (Flashbots Protect). +4. On confirmed execution: record `ConversionExecution`, allocate output to deposits (§11.4), notify the user with amounts and tx hash (notification correction path per §11.3). + +### 5.3 Exit and recovery + +- Any owner (passkey or recovery owner) can execute arbitrary Safe transactions via plain `execTransaction` — withdraw, disable the module, change `destination`, or sign a Monerium redeem order (EIP-1271). No ERC-4337 in v1: Vortex relays owner-signed transactions and pays gas; **independently**, any funded account can submit `execTransaction` with valid owner signatures. +- **Disaster-recovery package (mandatory deliverable, F11):** public, versioned tooling that reconstructs the account from chain data + manifest, produces the WebAuthn assertion under the correct RP ID (requires the RP domain — see §8.2), builds and submits `execTransaction` against any RPC, without any Vortex service. Tested in CI against a fork. +- **Passkey loss:** automation continues (keeper needs no user signature); the recovery owner restores user control. Loss of **both** owners: automation still delivers future deposits to `destination`; stranded EURe (paused route) is unrecoverable — disclosed at onboarding. + +## 6. Account provisioning + +### 6.1 Components (exact pins required before audit) + +- Canonical Safe v1.4.1: singleton, `SafeProxyFactory`, `CompatibilityFallbackHandler` — pinned by address **and runtime hash** in the manifest schema. No custom factory; deployment uses `createProxyWithNonce` + a minimal audited `VortexSetupLib` (delegatecalled from Safe `setup`) whose only job is `enableModule` + `module.initialize` (F04 Q4: the custom surface is one small library, not a factory). +- Safe WebAuthn signer contracts (shared verifier or per-user signer proxy — decide in G0 with gas benchmarks; EIP-7951 path preferred). +- Fallback handler is the canonical `CompatibilityFallbackHandler` **only** (needed for EIP-1271 link validation). No 4337 module, no custom handlers (F05, F18). + +### 6.2 `VortexSwapModule` topology (F04 — Option B) + +One immutable singleton deployment; per-Safe configuration in storage. + +```solidity +// Immutable (constructor): EURE, EURC, USDC, UNISWAP_ROUTER, ORACLE, ORACLE_DECIMALS, +// MAX_ORACLE_AGE, SLIPPAGE_BPS, MAX_FEE_BPS, FEE_RECIPIENT, PATH (EURe -0.05%- EURC -0.05%- USDC), +// MIN_SWAP_FLOOR, CAP_CEILING, LIVENESS_FALLBACK_DELAY +// Storage: +// struct Config { address destination; uint16 feeBps; uint64 initializedAt; bool userPaused; } +// mapping(address safe => Config) config; +// Ops params (bounded): minSwapAmount ∈ [MIN_SWAP_FLOOR, ...], perSwapCap ∈ [..., CAP_CEILING]; +// globalPaused; guardian (Vortex ops multisig); paramTimelock. +``` + +- `initialize(destination, feeBps)`: callable once per Safe, **only** with `msg.sender == safe` (holds during atomic setup: the setup library runs in the Safe's context and the module sees the Safe proxy as caller). `feeBps ≤ MAX_FEE_BPS`. Reverts on re-init. Not front-runnable: config is keyed by `msg.sender`, so only the Safe can create its own entry. +- `setDestination(addr)` / `setUserPaused(bool)`: `msg.sender == safe` only (i.e., an owner-signed Safe transaction). `feeBps` immutable after init. +- Every mutation emits events with a config version counter (F04, F14). + +### 6.3 Destination semantics + +Set at onboarding, part of the manifest and the disclosure. Changeable only via the Safe (S3). Ownership attestation is legal, not cryptographic — requiring a signature from the destination key would contradict the wallet-less UX and is explicitly not claimed (F03). + +### 6.4 Configuration manifest and verification (F02) + +Per account, a versioned JSON manifest: chain ID; Safe address; singleton + proxy factory + fallback handler addresses and runtime hashes; owners and threshold; enabled modules; module address, runtime hash, and full config (destination, feeBps); signer contract coordinates; oracle and router addresses; setup tx hash. Published to a public transparency log (repo + API). An **independent verifier** (open-source script, runnable by anyone against public RPC) checks live chain state against the manifest; onboarding blocks on it, and it re-runs continuously with alerting. This makes provisioning fraud *detectable before first deposit* — the claim stops there. + +## 7. Conversion policy (on-chain) + +### 7.1 `swapAndForward(safe)` + +Caller: authorized keeper set; **permissionless fallback** — anyone may call once the Safe's EURe balance has exceeded `minSwapAmount` for longer than `LIVENESS_FALLBACK_DELAY` (proposal: 24h). Timing-grief within the slippage bound is accepted and bounded (F06 Q, review §6.4). + +Atomic sequence (reverts as a unit; reentrancy-guarded; all external calls `CALL` with `value == 0`; safe-ERC20 handling for return values): + +1. Require: not `globalPaused`, not `userPaused`, config initialized. +2. `balance = EURE.balanceOf(safe)`; require `balance ≥ minSwapAmount`; `amountIn = min(balance, perSwapCap)`. +3. Compute `minOut` (§7.3). +4. Via `execTransactionFromModule` (CALL only): `EURE.approve(UNISWAP_ROUTER, amountIn)` (force-approve pattern). +5. Via `execTransactionFromModule`: `UNISWAP_ROUTER.exactInput({path: PATH, recipient: safe, amountIn, amountOutMinimum: minOut})` — **calldata constructed entirely by the module** (F06); path, router, recipient hard-pinned; deadline semantics per the pinned router version (SwapRouter takes an explicit deadline — set `block.timestamp`; SwapRouter02 omits it — decide at pin time in G0). +6. Verify: EURe allowance to router == 0 (reset if router pulled less; then also verify EURe delta == amount actually swapped), `usdcDelta = USDC.balanceOf(safe) − pre ≥ minOut`. +7. `fee = usdcDelta × feeBps / 10_000` → `FEE_RECIPIENT` (pilot: 0); remaining full USDC balance → `config.destination`. If the destination transfer reverts (e.g. Circle blacklist), **the whole execution reverts and funds remain EURe** (F17.1). +8. Emit `SwapExecuted(safe, amountIn, usdcOut, fee, roundId)`. + +Scope statement (F06): the guarantee covers **EURe and USDC in a dedicated Safe provisioned by this flow**. Assets or approvals the user independently adds to the Safe are outside the policy's protection (the module never touches them, but a future user-granted allowance is the user's own act). + +### 7.2 What was removed (F06, F07) + +No keeper-supplied calldata, no selector allowlists, no generic router registry, no aggregators, no CoW. Route governance in v1 is binary: the single pinned route can be **paused instantly** by the guardian (protective, instant) ; any expansion (new route/module version) is a new deployment + per-user owner-authorized migration (§12) — i.e., additions are maximally slow, removals are instant. + +### 7.3 Oracle math (F09) + +```text +(roundId, answer, , updatedAt, ) = ORACLE.latestRoundData() +require(answer > 0 && updatedAt != 0) +require(block.timestamp − updatedAt ≤ MAX_ORACLE_AGE) // immutable ceiling +// EURe: 18 dec; ORACLE_DECIMALS: read once at deploy (expect 8); USDC: 6 dec +// scale = 10^(18 + ORACLE_DECIMALS − 6) → 10^20 for an 8-dec feed +minOut = mulDiv(amountIn, uint256(answer) × (10_000 − SLIPPAGE_BPS), + 10 ** (12 + ORACLE_DECIMALS) × 10_000) // floor; conservative direction, error < 1 unit +``` + +- **A4 stated:** USDC/USD is assumed 1.0; the SLIPPAGE_BPS margin absorbs both stablecoin bases. USDC below the margin ⇒ swaps revert (protective). No USDC/USD feed in v1 (documented decision; revisit if margin proves tight). +- **Weekend policy:** Chainlink FX feeds hold the last market price outside trading hours. Verify in G0 whether heartbeat updates continue (staleness passes) or stop (swaps revert Fri→Mon). If they continue: execute normally — the slippage margin has historically absorbed weekend EUR/USD gaps — but keeper policy defers swaps above a size threshold to market hours; disclosed. If they stop: deposits queue until Monday; SLA disclosure reflects it. +- Loss statement (F09): *the swap cannot deliver less than `(1 − SLIPPAGE_BPS)` of the oracle-model value, assuming an honest oracle and modeled token behavior.* This is not a principal bound under oracle or stablecoin failure (see S2 assumptions). + +### 7.4 Liquidity: measurement, caps, monitoring (F12) + +- `minOut` is the **safety** condition. `perSwapCap` / `minSwapAmount` are **availability** parameters (bounded by immutable floor/ceiling; lowering instant, raising behind the ops timelock). +- Launch methodology: record block-numbered `QuoterV2` static-call quotes at {1k, 5k, 10k, 25k} EURe plus active-tick liquidity for both pools; archive parameters for reproducibility. TVL alone never justifies raising caps. +- Continuous monitoring: executable quote at `perSwapCap` vs oracle; alert and auto-engage keeper pause when impact at `minSwapAmount` exceeds SLIPPAGE_BPS for a sustained period (launch/pause thresholds defined in runbook). +- Rapid successive executions beyond depth **revert on `minOut`** (availability loss, keeper gas waste — not fund loss); pacing is keeper policy, best-effort, and documented as such. + +## 8. Keys and recovery + +### 8.1 Owners + +Safe owners: `[SafeWebAuthnSigner(passkey), recoveryOwner]`, threshold 1. Either owner has full unilateral control — both are user-controlled; this is disclosed. Vortex holds no owner key. + +### 8.2 RP-ID dependence (F11) + +The passkey works only via an origin under Vortex's RP ID. Mitigations: (a) the mandatory recovery owner is RP-independent (EOA/hardware/second passkey); (b) the disaster-recovery package includes a static, self-hostable page for the RP domain, and Vortex commits to a domain-continuity plan (registrar lock, escrowed transfer instructions) — documented limitation, not fully eliminable; (c) credentials required discoverable + backup-eligible. + +### 8.3 Removed from v1 (F05) + +The automatic EIP-1271 redeem validator ("redeem to pinned refund IBAN without user signature") is removed: it was wrong-layered (EIP-1271 lives in the fallback handler, not a module), required a bespoke signature-encoding protocol, risked weakening link-message validation, and gave Vortex unilateral disposal authority that changes the custody analysis. Stranded-EURe recovery in v1 = owner-signed redeem or withdrawal. Residual: loss of both owners + paused route strands EURe (disclosed; accepted). + +## 9. Fees (F10) + +- Structure finalized now: per-account `feeBps` set at `initialize`, **immutable thereafter**; global immutable `MAX_FEE_BPS` (proposal: 100) and immutable `FEE_RECIPIENT` in the singleton. Fee assessed per execution on gross swap output; batching therefore charges each batched deposit pro-rata by construction (§11.4). +- **Pilot: `feeBps = 0`** (loss-leader; Vortex pays deployment + keeper gas). GA fee value is a business decision (OQ1) — changing it means new accounts get a different `feeBps`; existing accounts keep theirs. +- I1 (S2 guarantee) enumerates the treasury as a permitted recipient bounded by `feeBps ≤ MAX_FEE_BPS`. Disclosure separates fee (deterministic) from slippage bound (worst-case market execution). + +## 10. Product behavior: minimums, dust, destinations (F16) + +### 10.1 Minimum deposit & SLA + +User-facing: deposits ≥ €25 convert within a stated SLA (proposal: 1 business hour under normal conditions; next FX market open under the weekend policy). Deposits < €25 **accumulate** until the threshold is crossed; always recoverable via owner-signed exit. `minSwapAmount` is gas-responsive within its immutable floor. + +### 10.2 Gas griefing + +Many small SEPA transfers can force keeper gas. Bounded by: min threshold + natural batching (balance sweep), sender is a KYC'd bank customer (low realistic abuse), and per-account keeper budget alerts. Vortex cannot prevent inbound SEPA to an issued IBAN; accepted operational cost. + +### 10.3 Batching + +Deposits arriving before conversion batch naturally (module sweeps balance). Allocation rule in §11.4; batching latency covered by the SLA disclosure. + +### 10.4 Destination validation + +At onboarding: EIP-55 checksum; deny zero/dead addresses, precompiles, the Safe itself, the module, the router, known token contracts; warn for contract destinations (recoverability unprovable) and exchange deposit addresses (rotation, minimum-deposit thresholds — user attests awareness); sanctions/blacklist screen at onboarding and **periodic re-screening** with conversion pause + user notification on a hit (F15, F16). Blacklisted destination at execution time ⇒ atomic revert, funds stay EURe (§7.1.7). + +## 11. Backend (F08, F13) + +### 11.1 Data model — replaces the one-shot ramp machine for this product + +The existing `PhaseProcessor` is **not** reused: its documented non-atomic multi-instance lock (spec finding F-003) and retry-exhaustion gap (F-004) are unacceptable for a permanent, repeatedly funded account. + +```text +MoneriumAccount: profileId, iban, safeAddress, configVersion, status (onboarding|active|suspended|closed), complianceState +FiatDeposit: moneriumOrderId (unique), amount, currency, paymentStatus, mintTx {chainId, txHash, logIndex, blockHash}, complianceStatus +ConversionExecution: safeAddress, includedDepositIds[], eureIn, usdcGross, fee, usdcNet, destination, txHash, status, error +``` + +Idempotency keys: `moneriumOrderId` (accounting identity) and `(chainId, txHash, logIndex)` (on-chain identity). **On-chain balance is the execution-safety source; Monerium order IDs are the accounting source.** + +### 11.2 Webhooks + +HMAC verification over raw request bytes, constant-time compare, timestamp/replay window, persisted webhook-ID dedup, immediate `200` + async processing, out-of-order tolerance (state machine on `FiatDeposit.paymentStatus` accepts only forward transitions). + +### 11.3 Chain handling + +Confirmation policy: detect mints at 1 confirmation; execution reads live balance (safety source) so reorged mints self-correct; user **notifications** only after the execution tx reaches N confirmations (proposal: 32 blocks) with block-hash re-verification; a reorg after notification triggers a correction notice. Keeper: unique nonce manager, stale private-relay tx replacement policy. + +### 11.4 Concurrency & attribution + +Per-Safe serialization via Postgres advisory lock (`pg_advisory_xact_lock(hash(safeAddress))`) — contract reentrancy guards do not serialize separate keeper processes (F13). Batched output allocation: pro-rata by deposit amount, floor to 6 dp, remainder to the largest deposit — deterministic and auditable; fees allocated identically. + +### 11.5 Partner/API surface (F08 Q) + +The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDeposit`/`ConversionExecution` objects with webhooks per deposit — **not** one eternal ramp object. + +## 12. Incidents and migration (F14) + +- **Pause:** guardian pauses globally or per-account **instantly** (protective-only action). Unpause instant (config is user/immutable-controlled, so unpause cannot enact a hostile change). +- **02:00-UTC module vulnerability runbook:** pause all → ask Monerium to suspend affected IBANs (capability to be confirmed in MSA — G1) → notify users to stop sending EUR (email/app + status page) → assess → ship migration. +- **Migration:** deploy new module singleton (new audit) → each user authorizes `enableModule(new)` + `disableModule(old)` via an owner signature (relayed by Vortex; also possible via the DR package). The Safe address — and therefore the IBAN link — **does not change**, avoiding F01's re-association path. Users who never migrate keep the paused old module; funds remain owner-recoverable. +- Module/config version discovery: on-chain events + manifest log. Old-version support/sunset policy published. +- Users who lost all owners: automation (if unpaused) still forwards; otherwise funds sit; no backdoor exists by design — disclosed. + +## 13. Launch gates (external dependencies) + +- **G0 — Technical spike:** Monerium sandbox E2E (deploy → EIP-1271 link → mint → swap on fork); pin Safe/WebAuthn contracts + gas benchmark (EIP-7951 path); confirm router version & deadline semantics; confirm Chainlink EUR/USD feed address, decimals, heartbeat, and **weekend update behavior**; reproducible liquidity baseline (§7.4). +- **G1 — Monerium MSA (blocking for the S1 claim, F01):** written + sandbox-verified answers on: authorization required for `PATCH /ibans` and `POST /addresses` on whitelabel profiles; whether an IBAN/profile can be locked to a single non-movable address absent end-user authorization; credential scoping; association-change event feed; per-IBAN suspension capability; SEPA recall/fraud loss allocation **after** conversion+forwarding; behavior of conversions during profile review/suspension. If pinning is unavailable: launch is still possible with the S1 trust statement as written (Vortex trusted pre-mint) + monitoring — a product/legal decision to make explicitly. +- **G2 — Legal/compliance sign-off (F15):** custody analysis covering module authority **and** Monerium control-plane authority; MiCA scoping; DPA/controller-processor roles with Monerium; retention/access table and data-flow diagram; sanctions screening procedure; disclosure texts. +- **G3 — Audit:** contracts (module + setup lib) with invariant/fuzz suites covering §7.1 post-conditions, init front-running, pause semantics, oracle edge cases, V1-token poisoning; DR package tested on fork. +- **G4 — Pilot:** invite-only, personal profiles, `feeBps = 0`, `perSwapCap` conservative (≈ €5–10k), €1k/user/day operational limit, full monitoring live. + +## 14. Open questions (reduced) + +- **OQ1** — GA fee value (structure is finalized; §9). +- **OQ2** — `LIVENESS_FALLBACK_DELAY`, SLA numbers, cap/threshold launch values (G0 data). +- **OQ3** — Weekend policy final form (depends on G0 feed behavior). +- **OQ4** — G1 outcome: does the S1 statement get upgraded (Monerium pinning) or stay trust-based? +- **OQ5** — Recovery-owner UX default (second passkey vs printable key as the recommended path). + +--- + +## Appendix A — Response to architecture review findings + +| ID | Disposition | Response / change | +|---|---|---| +| F01 | **Accept** (independently verified) | `PATCH /ibans` bearer-auth redirect confirmed against live docs; memo-routing sub-claim not found in current docs (noted, immaterial). Trust model rewritten (S1); Monerium controls made launch gate G1; association monitoring + credential isolation added. Absolute non-custody claim retracted | +| F02 | **Accept** | §8.3 replaced by per-stage guarantee matrix (§4); versioned config manifest + independent verifier + continuous re-verification (§6.4) | +| F03 | **Modify** | Finding accepted: link signature binds nothing beyond address ownership; "implicitly ratifies" retracted. Resolution = review's Option 3 (trusted provisioning, stated plainly) + manifest verification. Review's Option 1 (extra EIP-712 passkey signature) **rejected as a trust upgrade**: WebAuthn lacks what-you-see-is-what-you-sign, so under a compromised frontend it yields an audit artifact, not consent — equivalent to Option 3 in the threat model it targets. Destination attestation is legal consent (§6.3). "One signature" demoted to UX description (§5.1.7) | +| F04 | **Accept** | Option B selected: immutable singleton + Safe-keyed config, `initialize` once with `msg.sender == safe` (atomic via setup library; keyed-by-caller ⇒ not front-runnable), `setDestination` Safe-only, `feeBps` immutable post-init, versioned events (§6.2). Safe v1.4.1 pinned by address + runtime hash; custom factory dropped for canonical factory + minimal setup lib | +| F05 | **Accept** | Correct on all points (module ≠ fallback handler; hash-only 1271 input; IBAN canonicalization; replay; link-message weakening; unilateral-disposal custody impact). Auto-redeem validator removed from v1 (§8.3); recovery = mandatory independent owner + owner-signed redeems. Fallback handler = canonical CompatibilityFallbackHandler only | +| F06 | **Accept resolution; correct one argument** | v1: module constructs all calldata; pinned router/path/recipient; no keeper calldata; no registry; guarantee scoped to EURe/USDC in the dedicated Safe; instant pause vs deployment-grade additions (§7.1–7.2). Correction: balance-delta post-conditions already defeat recipient/path redirection of swap output atomically — the genuine residual was other assets/approvals and nested calls, which the scoping + internal-calldata resolution addresses | +| F07 | **Accept** | CoW removed from same-interface claim; deferred to a standalone v2 spec with its own lifecycle/threat model (order authorization, watchtower, settlement-time 1271, partial fills, fallback-handler coexistence) | +| F08 | **Accept** (verified in-repo) | Spec findings F-003/F-004 confirmed in `docs/security-spec/03-ramp-engine/state-machine.md`. New persistent model (§11.1), idempotency keys, advisory-lock serialization, per-deposit API objects (§11.5). New security-spec file required per repo sync rule; legacy `05-integrations/monerium.md` superseded-by link | +| F09 | **Accept** | Raw-unit pseudocode with `10^(12+ORACLE_DECIMALS)` scaling (=10^20 at 8 dec), read-once decimals, staleness ceiling, floor rounding, explicit A4 (USDC/USD = 1 within margin), weekend policy pending G0 feed-behavior check; loss statement rewritten as oracle-model-relative (§7.3) | +| F10 | **Accept** | Fee structurally finalized: per-account immutable `feeBps` (pilot 0), immutable `MAX_FEE_BPS` + treasury; I1/S2 updated to enumerate the fee recipient; per-execution assessment with pro-rata batch allocation (§9, §11.4) | +| F11 | **Accept** | RP-ID dependence acknowledged (§8.2). Independent recovery owner **mandatory** at onboarding (§5.1.3); DR package (Vortex-independent, fork-tested, incl. self-hostable RP page) a launch deliverable; domain-continuity plan documented; credentials discoverable + backup-eligible required | +| F12 | **Accept resolution; correct one argument** | Caps reframed as availability parameters; `minOut` is the safety condition; block-numbered reproducible quoting methodology + continuous executable-depth monitoring + pause thresholds (§7.4). Correction: rapid cap-sized executions revert on `minOut` rather than execute at bad prices — availability/gas loss, not fund loss | +| F13 | **Accept** | Full webhook (HMAC/dedup/replay), confirmation/reorg, nonce, advisory-lock, and allocation spec added (§11.2–11.4); balance = safety source, order IDs = accounting source | +| F14 | **Accept** | Instant guardian pause; incident runbook incl. Monerium IBAN suspension (G1 question); owner-authorized module migration that **keeps the Safe address** (avoids F01 re-association); version discovery; sunset policy (§12) | +| F15 | **Accept** | v1 = personal, newly onboarded only; G2 gate for legal/DPA/retention/sanctions; SEPA-recall loss allocation moved into G1 MSA questions; destination re-screening added (§10.4) | +| F16 | **Accept** | Min deposit + accumulation + SLA disclosure (§10.1); gas-griefing bounded and accepted (§10.2); destination validation/denylist/warnings/re-screening (§10.4). Noted: griefing realism is low (KYC'd bank senders) but policy specified regardless | +| F17 | **Accept** | All six corrections applied: atomic revert on blacklisted destination (funds remain EURe); "Vortex executes only the constrained policy"; withhold/censor language scoped; extraction bound restated as oracle-model-relative incl. fee; passkey-sync nuance; EIP-7951 treated as live with G0 benchmarking of the pinned implementation | +| F18 | **Accept** | v1 composition cut to: canonical Safe + passkey signer + recovery owner + one module (internal calldata) + canonical fallback handler. Removed: 4337, custom factory, generic registry, aggregator calldata, CoW, auto-redeem validator, mutable fees. Matches review §6 with one divergence: module topology is Option B (singleton+config) rather than per-Safe clones — one audited deployment, no per-user bytecode, equivalent immutability of logic | + +**Requested end-to-end statement (review §8):** the composition now supports this security statement — *"Once EURe is minted to the user's Safe, Vortex's total authority is: execute the fixed EURe→USDC→destination conversion within an oracle-checked slippage bound and a disclosed fee; pause it; and tune bounded availability parameters. Redirecting or extracting minted principal beyond the slippage margin + fee requires breaking a stated assumption (oracle integrity, token-contract behavior, audited-code correctness) — not merely abusing any authority Vortex holds. Before mint, Vortex and Monerium hold monitored, contractually constrained, but real authority over deposit routing; users are told so."* diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md new file mode 100644 index 000000000..2c5cc6b78 --- /dev/null +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -0,0 +1,70 @@ +# Monerium Onramp — Deferred Decisions Registry + +**Purpose:** single place for every parameter and decision we deliberately postponed so implementation can start. Nothing in here blocks coding; each row states the placeholder used in code/spec until decided. Review this file at every phase gate. + +**Last updated:** 2026-07-17 + +## Business decisions (Marcel / partner) + +| # | Decision | Placeholder until decided | Needed by | +|---|---|---|---| +| B1 | GA `feeBps` value (structure is built; per-client, immutable at init) | `0` (pilot) | Before first paying client | +| B2 | Penny-test amount for destination verification | 5 USDC | Pilot onboarding runbook | +| B3 | Processing SLA wording for client terms (incl. weekend behavior) | "within 1 business hour; FX-market-hours caveat" | Terms drafting (with G2) | +| B4 | Pilot client list + per-client volume limits | €1k/client/day | G4 pilot start | +| B5 | Partner liability terms: destination warranty, rotation-loss allocation, dormancy re-confirmation mechanics | — | Partner agreement signing | +| B6 | Redemption-limitation disclosure text (Monerium requires it; commitment made in TG thread) | Draft in variant doc §6 | Terms drafting (with G2) | + +## Contract parameters (decide before mainnet deploy; placeholders fine for sandbox/testnet) + +| # | Parameter | Placeholder | Notes | +|---|---|---|---| +| P1 | `SLIPPAGE_BPS` | 100 (1%) | Immutable; absorbs EURe + USDC basis vs Chainlink EUR/USD | +| P2 | `MAX_FEE_BPS` | 100 (1%) | Immutable ceiling for per-client feeBps | +| P3 | Dead-man sweep delay (stranded EURe → fallbackAddress) | 60 days | Immutable; uses on-chain `strandedSince` marker (R03 fix) | +| P4 | Permissionless swap-trigger delay | 24 h | Same marker as P3 | +| P5 | Dormancy pause window (no successful forward → pause pending re-confirmation) | 60 days | Operational (backend-enforced via per-account pause), not immutable | +| P6 | `minSwapAmount` floor / operational value | €25; must also be ≥ CEX min deposit per client | Floor immutable, operational value adjustable within bounds | +| P7 | `perSwapCap` operational value + immutable ceiling | €10k / €50k | Availability parameter, not safety (minOut is safety) | +| P8 | `MAX_ORACLE_AGE` | 26 h → **recommend 52 h** | T2 answered (2026-07-17): feed updates through weekends but sparsely — observed gaps 32.6 h / 36.1 h / **48.0 h**. 26 h would revert most weekends; 52 h covers observed max + margin, weekend EUR/USD moves sit well inside the 100 bps slippage bound | +| P9 | Notification confirmation depth | 32 blocks | Backend only | +| P10 | Uniswap router pin (SwapRouter w/ deadline vs SwapRouter02 w/o) + EURC hop fee-tier re-verification | SwapRouter02 | G0 spike output | + +## Technical clarifications pending (external) + +| # | Item | Owner | Status | +|---|---|---|---| +| T1 | **Monerium recovery-burn mechanism for contract addresses**: exact message/hash their recovery flow validates via EIP-1271, so the forwarder can whitelist it (compile-time constant `RECOVERY_HASH`). If unanswered by deploy time: ship without it — fallback-address recovery covers us; issuer backstop becomes best-effort. **Requirement (review r1)**: enable only if the recovery message is parameterless or payout-neutral — a parameterized message validated by our attestor would grant Vortex disposal discretion | Monerium tech team (compliance punted) | **Asked? No — send follow-up** | +| T2 | Chainlink EUR/USD weekend behavior → weekend policy | G0 spike | **Answered 2026-07-17**: rounds observed on Sat/Sun (deviation-triggered), gaps up to 48 h; see P8. Weekend policy: execute normally with `MAX_ORACLE_AGE ≥ 52 h` | +| T6 | Liquidity baseline (review F12 reproducibility) | G0 spike | **Recorded 2026-07-17, mainnet block 25553101**, QuoterV2 on pinned path EURe→(500)→EURC→(500)→USDC: 1k → 1.14298, 5k → 1.14288, 10k → 1.14278, 25k → 1.14252 USDC/EURe (Chainlink same day 1.14410 — 25k within ~14 bps incl. 2×5 bps fees). Deeper than the 07-10 snapshot; €10k cap comfortable. Re-run at deploy + wire into monitoring (task 6) | +| T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | +| T4 | Whitelabel sandbox: verify EIP-1271 link works against a deployed forwarder E2E (G0 headline item) | Us | **VALIDATED 2026-07-17**: Monerium sandbox accepted the attestor-signed link (HTTP 201, `state: linked`) on first attempt and issued an IBAN (state approved) — zero client interaction. Hash variant presented: **EIP-191** → narrow the contract to `LINK_HASH_191` only before audit (drop `LINK_HASH_RAW`; folded into review-r1 follow-ups). Sandbox artifacts (Sepolia): factory `0x82f4953CF3ACaa464b67f932AAF008af010a9376`, forwarder `0x67592847844958b455ae907D3Ef1EADBf6827fdc`, MockOracle `0x337dd479435aE2593c9B023B48617278c6AB34E3`, profile `d2de6768-b0e7-11f0-a4ad-fabb3106d2e3`, IBAN `EE08 7224 5745 6244 9516`. Client API notes: `POST /addresses` body `{address, chain, message, profile, signature}` confirmed; `GET /profiles` list 404s (use per-profile paths); `POST /ibans` is async 202 → poll. **Re-validated with hardened binding** (EIP-191-only + chainid, review-r1 fixes) same day: factory2 `0xcBE354e847bF597513148918E7EbDff72aC75842`, forwarder2 `0xD7444AB7270A142227Fe659D63873ABdc8AF9b72`, link 201. Remaining G0 sliver: simulate SEPA deposit → observe mint + webhook (dashboard button at sandbox.monerium.dev → Receive → "Simulate bank transfer" — needs Marcel's dashboard login, one click) | +| T5 | Whether Monerium rejects linking an address already linked to another profile (defense-in-depth question) | Monerium tech | Nice-to-have | + +## G1 — written approval package to collect from Monerium + +All currently Telegram-only. Consolidate into MSA or side letter: + +1. Attestor-pattern acceptance (compliance said "fine if fallback capabilities maintained" — fallback is now mandatory, so condition is met by design). +2. Redemption-limitation disclosure obligation (their explicit request; our commitment). +3. Issuer recovery backstop: burn from linked address + payout only to customer's own external bank account, no fees, re-verification possible (their statements 2026-07-16/17) + T1 mechanics. +4. IBAN pinning: authorization required for `PATCH /ibans` / `POST /addresses` on whitelabel profiles (pre-existing G1 item — unresolved). +5. OAuth→whitelabel profile portability + whether whitelabel `client_id` auto-accesses existing profiles (pre-existing; Telegram-only). +6. SEPA recall / fraud loss allocation after conversion+forwarding (pre-existing; unresolved). +7. Per-IBAN suspension capability for incident response (pre-existing; unresolved). +8. T3 corporate KYB mechanism. + +## G2 — legal review scope (unchanged, not started) + +Custody opinion on attestor construction; MiCA exchange/transfer-service scoping (non-custody ≠ out of scope); disclosure enforceability; DPA/controller-processor with Monerium; sanctions screening procedure for destinations. + +## Decisions already made (do not reopen without cause) + +- B2B variant first; consumer passkey flow is phase 2 (2026-07-17). +- Tier C dropped: self-custodied `fallbackAddress` mandatory for every client (2026-07-17; aligns with Monerium condition). +- Target whitelabel API directly, develop against sandbox; no legacy-OAuth interim build (2026-07-17). +- Adversarial review runs in parallel with implementation (2026-07-17). +- Attestor-constrained `isValidSignature` (link hash only, attestor key only, bound to contract address); never a general owner key. +- Never send raw EURe to a CEX destination; EURe recovery targets are `fallbackAddress` only. +- No on-contract redeem validator (F05 stands); redemption path = fallback sweep → client redeems from own address; issuer recovery as break-glass backstop (pending T1). +- Fee structure: per-client immutable `feeBps` at init, immutable `MAX_FEE_BPS` + treasury (pilot 0). diff --git a/docs/runbooks/monerium-b2b-dormancy.md b/docs/runbooks/monerium-b2b-dormancy.md new file mode 100644 index 000000000..2c2cb1127 --- /dev/null +++ b/docs/runbooks/monerium-b2b-dormancy.md @@ -0,0 +1,64 @@ +# Runbook: Monerium B2B Onramp — Dormancy Gate + +Why this exists: CEX destination-rotation risk concentrates in dormant accounts — an exchange +silently rotates a deposit address, months later a deposit arrives, and USDC is forwarded to an +address the client no longer controls. Undetectable on-chain. The dormancy gate converts that +silent loss into a pause (b2b-variant doc §6). + +## Gate mechanics (automatic) + +Implemented in `apps/api/src/api/services/monerium-b2b/dormancy.ts`, run every keeper cycle: + +- An `active` account with **no confirmed conversion for 60 days** (placeholder — registry P5; + anchor = last confirmed `MoneriumConversionExecution`, or account creation if none) is paused: + the backend calls `setGuardianPaused(true)` on the clone with the guardian key and records + `dormant_since` on the `MoneriumAccount` row. +- If `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY` is unset, the gate runs **log-only**: `dormant_since` + is recorded and a warning states that no on-chain pause exists. +- While `dormant_since` is set, the conversion executor skips the account entirely. + +The pause is protective-only (contract invariant): it blocks `swapAndForward` and nothing else. +The client's fallback paths (`sweep`, `setDestination`, `setClientPaused`, …) and the +permissionless dead-man sweep (`sweepStrandedEure` after SWEEP_DELAY, registry P3) keep working. +EURe arriving during dormancy accumulates safely on the forwarder; if it strands past +SWEEP_DELAY it flows to the client's `fallbackAddress` automatically. + +## Re-confirmation (manual, via partner) + +Re-confirmation mechanics are a partner-agreement item (**registry B5**) — until settled, the +operational procedure is: + +1. Ask the partner to re-confirm with the client that the `destination` address is still valid + and under the client's control (a partner API ping/written confirmation suffices — zero + client UI by design). +2. If the destination changed: the **client** updates it via their fallback key + (`setDestination` from `fallbackAddress`) — Vortex cannot and must not do this. For CEX + destinations, re-run the penny test (onboarding runbook §7; amount registry B2). +3. Archive the confirmation evidence with the account record. + +## Un-pause + +Only after re-confirmation: + +```bash +cast send "setGuardianPaused(bool)" false --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Then clear the dormancy flag so the executor resumes: + +```sql +UPDATE monerium_accounts SET dormant_since = NULL WHERE forwarder_address = ''; +``` + +The next keeper cycle converts any accumulated balance. Verify: a `SwapExecuted` for the +forwarder, the execution row `confirmed`, and no `stranded EURe` alert on the next monitoring +pass. + +## Edge notes + +- Un-pausing without clearing `dormant_since` leaves the executor skipping the account (the DB + flag, not the chain flag, gates the executor) — always do both. +- A dormant account that re-confirms but stays unused simply re-enters the gate after another + window; that is intended. +- Do not un-pause to "flush" a balance without re-confirmation — the balance is exactly the + rotation-risk scenario the gate exists for. diff --git a/docs/runbooks/monerium-b2b-incident.md b/docs/runbooks/monerium-b2b-incident.md new file mode 100644 index 000000000..4ab340797 --- /dev/null +++ b/docs/runbooks/monerium-b2b-incident.md @@ -0,0 +1,112 @@ +# Runbook: Monerium B2B Onramp — Incident Response + +Scope: the B2B forwarder deployment (`contracts/monerium-forwarder/`) and its keeper/monitoring +backend (`apps/api/src/api/services/monerium-b2b/`). Spec: `docs/prd/monerium-b2b-implementation-plan.md`, +`docs/security-spec/05-integrations/monerium-b2b.md`. + +Ground rules that shape every procedure here: + +- **Vortex powers are delay-only.** Guardian/keeper can pause and execute the policy — never move + or redirect funds. There is no Vortex-side rescue path by design. +- **Pauses never trap client funds.** `fallbackAddress` functions (`sweep`, `setDestination`, + `setFallbackAddress`, `setClientPaused`) and the permissionless dead-man sweep + (`sweepStrandedEure`, after SWEEP_DELAY) work while paused. Do not promise otherwise in comms. +- **Never send raw EURe to a CEX destination.** EURe recovery targets are `fallbackAddress` only. + +## 1. Pause procedures + +The guardian key is `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY` (distinct from keeper and attestor keys). +`$RPC` = the chain RPC, `$FACTORY` = the factory address from the published manifest. + +**Per-clone pause** (one client account — compliance hold, dormancy, targeted issue): + +```bash +cast send "setGuardianPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +**Global pause** (all clones at once — protocol-level incident): + +```bash +cast send $FACTORY "setGlobalPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Both block `swapAndForward` only. Unpause = same call with `false`. Cap reduction (availability +lever, instant, bounded by immutables): + +```bash +cast send $FACTORY "setPerSwapCap(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +## 2. Monerium IBAN suspension ask + +Per-IBAN suspension capability is **G1 item 7 — not yet contractual** (registry). Until the MSA +settles it, this is a best-effort ask: + +1. Contact Monerium support/emergency channel; identify the whitelabel partner account and the + affected IBAN(s) + linked forwarder address(es). +2. Ask for: suspension of inbound SEPA on the IBAN(s) (deposits bounce back to senders), NOT + profile closure. +3. Record ticket/response — feed the outcome back into the G1 negotiation record. + +While unsuspended, inbound SEPA keeps minting EURe to the forwarder. That is safe (funds sit +behind the contract's invariants) but grows exposure — factor it into comms urgency. + +## 3. User notification + +Clients have no Vortex UI; all comms run through the partner plus direct email. + +1. Notify the partner ops contact first (they own the client relationship). +2. Email affected clients: **stop sending EUR to your IBAN until further notice**; deposits + already sent will either convert normally after resolution or be recoverable via the + fallback address — no funds are lost by pausing. +3. Status page entry if the pause is global. + +## 4. Critical-vulnerability sequence (the 02:00-UTC runbook) + +Adapted from PRD v2 §12 for the B2B topology (immutable clones, no per-user migration signature). +Assume a suspected vulnerability in `VortexForwarder`/factory: + +1. **Pause all** — `setGlobalPaused(true)` (§1). Instant, protective-only, reversible. +2. **Ask Monerium to suspend affected IBANs** (§2) so no new EURe mints while assessing. +3. **Notify** partner + clients to stop sending EUR (§3). +4. **Assess.** Funds at risk are EURe balances on forwarders (check with the stranded-balance + monitor output or `cast call "balanceOf(address)" `). Run the manifest + verifier against the live deployment as part of the assessment: + `bun script/verify-manifest.ts $RPC` (from `contracts/monerium-forwarder/`). +5. **If funds must move: only clients can move them.** Instruct clients (via partner) to sweep + EURe to safety with their fallback key: `sweep(EURE, )` from `fallbackAddress`. + Provide exact calldata and a verification walkthrough. The issuer recovery backstop + (Monerium burn + payout to the client's own bank account) is the last resort — best-effort + until registry T1 is resolved. +6. **Ship the fix as a migration.** Immutable contracts: deploy fixed implementation + factory + (new audit), deploy new clones per client, link the new clone to the client's profile + (attestor flow, onboarding runbook §3), issue/move the IBAN, penny-test, regenerate + publish + the manifest. Old clones stay paused; residual balances leave via fallback sweep or dead-man + sweep (never lost — SWEEP_DELAY, registry P3). +7. **Unpause / decommission.** Unpause only contracts that are confirmed unaffected. + +## 5. Alert triage (monitoring log lines → action) + +The monitors live in `apps/api/src/api/services/monerium-b2b/monitoring.ts` (worker-run, every +30 min). All 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 (PRD §7.4 pause threshold); swaps would revert on minOut | Engage global pause (§1); investigate pool state (LP exit, depeg); consider lowering `perSwapCap`; re-run the T6 quote methodology before unpausing | +| `executable depth below perSwapCap` | Cap-sized swaps would revert; availability, not fund risk | Lower `perSwapCap` (§1) or accept keeper retries; watch for escalation to pause threshold | +| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB record (IBAN moved, address linked) — the S1 detective control | Treat as potential whitelabel-credential compromise: confirm with Monerium whether the change was authorized; if not: global pause, rotate `MONERIUM_B2B_CLIENT_SECRET`, ask for IBAN suspension (§2), full incident | +| `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas balance, oracle staleness (swaps revert on `StalePrice`) | +| `stranded EURe ... past TRIGGER_DELAY` | ≥ TRIGGER_DELAY (registry P4) — permissionless trigger now live; SLA long since broken | Escalate keeper outage; anyone can call `swapAndForward()` now, which is acceptable (same policy applies); communicate delay to client | +| `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on factory` | Should-be-impossible state (immutable feeBps changed, wrong code) | Full incident: global pause, run the manifest verifier, compare against the published manifest history | +| `reconciled owner-authorized config change` | Client's fallbackAddress rotated destination/fallback — expected transition (R07), DB updated | No incident. Confirm with the partner that the client intended it; an unexpected change suggests a compromised fallback key → client should `setClientPaused(true)` and rotate via `setFallbackAddress` | +| `MONERIUM_B2B_PRIVATE_RPC_URL is not set` | Keeper writes going through public mempool | Operational finding on mainnet — set the private orderflow RPC | + +## 6. Key compromise quick reference + +| Key | Blast radius | Response | +|---|---|---| +| Attestor | Can link addresses to profiles, never move funds | Rotate key; new forwarders need a new implementation deployment (ATTESTOR is immutable); existing links unaffected | +| Keeper | Can call `poke`/`swapAndForward` (policy-constrained) — no fund redirection possible | Rotate `MONERIUM_B2B_KEEPER_PRIVATE_KEY`; `setKeeper(old,false)` + `setKeeper(new,true)` on the factory | +| Guardian | Can pause/unpause and tune bounded params — delay-only | Two-step `transferGuardian`/`acceptGuardian` to a new key; audit pause state afterwards | +| Whitelabel API credentials | Control-plane: could move IBANs/links at Monerium (S1) | Rotate at Monerium; association monitor is the detective control; check its history for unauthorized changes | +| Client fallback key (client-side) | Full control of that client's funds/config | Client's own responsibility (terms); assist via partner: pause account, client rotates `setFallbackAddress` if still in control | diff --git a/docs/runbooks/monerium-b2b-onboarding.md b/docs/runbooks/monerium-b2b-onboarding.md new file mode 100644 index 000000000..92be4239e --- /dev/null +++ b/docs/runbooks/monerium-b2b-onboarding.md @@ -0,0 +1,114 @@ +# Runbook: Monerium B2B Onramp — Client Onboarding + +Deploy → manifest → verify → link → IBAN → penny test → activate. One pass per client. +Spec: `docs/prd/monerium-b2b-implementation-plan.md`; API call shapes below are the +sandbox-validated ones from registry item T4 (2026-07-17). + +Prerequisites: guardian key funded on the target chain; `MONERIUM_B2B_*` env set +(API creds, attestor key, RPC); partner paperwork complete. + +## 1. Paperwork inputs (from the partner agreement) + +- `destination` — client's payout address. CEX deposit addresses allowed; validate: EIP-55 + checksum, not zero/dead/precompile/token/router (the contract re-rejects token/router/self at + init), warn-and-attest for contract addresses and CEX addresses (rotation risk — terms doc). +- `fallbackAddress` — client's **self-custodied** recovery address. Mandatory, no exceptions + (Monerium acceptance condition). Must be distinct from custodial/CEX addresses. +- `feeBps` — per-client, immutable post-init. Pilot: `0` (registry B1). +- Signed terms including the redemption-limitation disclosure (registry B6 — Monerium requires + it; see `docs/prd/monerium-b2b-terms-inputs.md`). + +## 2. Deploy the forwarder + +```bash +# predict, then deploy (guardian-only); salt = any unused bytes32, convention: client index +cast call $FACTORY "predictAddress(bytes32)(address)" $SALT --rpc-url $RPC +cast send $FACTORY "deployForwarder(address,address,uint16,bytes32)" \ + $DESTINATION $FALLBACK $FEE_BPS $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). Record the +forwarder address + deploy tx hash. + +## 3. Manifest: generate, verify, publish + +From `contracts/monerium-forwarder/`: + +```bash +bun script/generate-manifest.ts $FACTORY $RPC manifests/-$FACTORY.json +bun script/verify-manifest.ts manifests/-$FACTORY.json $RPC # must PASS +``` + +(Some free RPCs refuse historical `eth_getLogs`; add `--logs-rpc ` for +the event enumeration — all other reads stay on `$RPC`.) + +Publish the manifest (commit + public location). The manifest is **consistency evidence, not a +trust root** (re-review R01): it lets anyone detect silent changes; it does not prove the +deployment was honest — that requires the verified source on the block explorer, so verify the +factory + implementation source there as part of this step. + +## 4. Create the Monerium profile + KYB + +``` +POST /profiles { "kind": "corporate" } +``` + +Note: `GET /profiles` (list) 404s on the whitelabel sandbox — use per-profile paths +(`GET /profiles/{id}`). KYB submission is deliberately unimplemented (`submitKybData` → 501) +until the whitelabel KYB mechanism is contractually settled — **registry T3**; for sandbox and +until then, KYB completion happens on Monerium's side. + +## 5. Link the forwarder address (attestor flow) + +The backend signs the fixed link message with the attestor key +(`signLinkAttestation` in `apps/api/src/api/services/monerium-b2b/attestor.ts` — bound to +chainid + forwarder address; the contract validates via constrained EIP-1271, EIP-191 variant +only). Sandbox-validated call shape (T4): + +``` +POST /addresses +{ + "address": "", + "chain": "", // e.g. "ethereum"; sandbox spike used Sepolia + "message": "I hereby declare that I am the address owner.", + "profile": "", + "signature": "" +} +``` + +Expected: HTTP 201, address `state: linked` — zero client interaction (validated 2026-07-17, +including the hardened chainid-bound re-validation; sandbox artifacts in registry T4). + +## 6. IBAN issuance + +``` +POST /ibans { "address": "", "chain": "" } +``` + +**Async: expect HTTP 202** (T4). Poll `GET /ibans` until the entry for the address appears with +state approved. Record the IBAN on the `MoneriumAccount` row (status stays `onboarding`) — +the association monitor treats the DB record as the reference state from here on. + +## 7. Penny test + +Purpose: prove the destination actually credits contract-originated USDC transfers (CEXes can +rotate or mis-credit) before real volume flows. + +1. Send a small SEPA deposit to the new IBAN (sandbox: dashboard at sandbox.monerium.dev → + Receive → "Simulate bank transfer"). Target forward amount: **5 USDC** (placeholder — + registry B2). +2. Keeper converts and forwards automatically once the balance ≥ `minSwapAmount`; for a + sub-minimum penny test, temporarily lower `minSwapAmount` (guardian, bounded by + `MIN_SWAP_FLOOR`) or fund up to the minimum. +3. **Partner/client confirms credit at the destination** (explicit written confirmation — + this is a diligence commitment in the terms, registry B5). + +## 8. Activate + +1. Set the `MoneriumAccount` row to `active`. +2. Confirm the monitoring pass picks the account up cleanly (no association/config alerts on + the next cycle). +3. Hand the client's IBAN over via the partner. Done. + +Failure at any step: nothing is at risk — the forwarder holds no funds until the client wires +EUR, and every recovery path (fallback sweep, dead-man sweep) is live from deployment. diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md new file mode 100644 index 000000000..9a589a1d9 --- /dev/null +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -0,0 +1,80 @@ +# Monerium B2B Whitelabel Onramp + +## What This Does + +The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives each corporate client a Monerium IBAN linked to a per-client `VortexForwarder` contract. SEPA deposits mint EURe to the forwarder; a keeper later swaps and forwards USDC to the client's destination. This spec covers the backend integration built in `apps/api/src/api/services/monerium-b2b/`: the whitelabel API client, the attestor signature for address linking, the webhook receiver with durable inbox, and the deposit processor. It is deliberately NOT part of the one-shot ramp state machine — accounts are persistent and repeatedly funded. + +**Provider type:** on-ramp (EUR → USDC) +**Fiat currencies:** EUR +**Chains involved:** Ethereum (forwarder contracts, EURe/USDC) +**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` +**API auth method:** OAuth client credentials (`MONERIUM_B2B_CLIENT_ID`/`MONERIUM_B2B_CLIENT_SECRET`) against `MONERIUM_B2B_API_URL` (sandbox `api.monerium.dev` by default); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) + +## Security Invariants + +1. **Attestor key stays in env and out of logs** — `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` is read from the environment only, never persisted, never returned by an API response, and never included in log lines or error messages (the not-configured error names the variable, not the value). +2. **The attestor signature authorizes linking only** — the attestor signs exactly `keccak256(abi.encodePacked(forwarderAddress, linkHash))` where `linkHash` is one of the two fixed hashes of `"I hereby declare that I am the address owner."` (EIP-191 personal hash or raw keccak). `VortexForwarder.isValidSignature` accepts nothing else, so a leaked attestor key can link addresses but can never move funds or change forwarder config. The backend MUST never sign arbitrary hashes with this key. +3. **Signature format matches the contract check** — 65-byte `r ‖ s ‖ v` with `v ∈ {27, 28}` and low-s (the contract rejects malleable signatures). Enforced by construction (viem canonical signatures) and pinned by unit test `attestor.test.ts`. +4. **Webhook HMAC over raw bytes, constant-time** — the `webhook-signature` header is verified as HMAC-SHA256 of the RAW request bytes (captured by a body-parser `verify` hook scoped to this route, never a re-serialization of parsed JSON) using `crypto.timingSafeEqual`, with a self-compare on length mismatch so timing does not leak the mismatch position. 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` (payload id when present, else sha256 of the raw bytes), so Monerium retries and duplicate deliveries can never double-create or double-apply a deposit event. +7. **Deposit status transitions are forward-only** — `pending → {minted, held, returned}`, `held → {minted, returned}`; `minted` and `returned` are terminal. Out-of-order or replayed webhook events can never regress a deposit status; regressive transitions are logged and ignored. Guarded by `isForwardTransition` (unit-tested). +8. **Per-forwarder serialization via advisory lock** — all deposit writes for one forwarder happen inside a transaction holding `pg_advisory_xact_lock(hashtextextended('monerium-b2b:' || lower(forwarderAddress), 0))`, so concurrent processors (multiple API instances, webhook-triggered plus scheduled runs) apply events for an account strictly one at a time. This is the same serialization point the execution/attribution logic (R04) will use. +9. **Deposit identity is the Monerium order id** — `monerium_order_id` is unique; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are stored as 18-decimal base-unit strings converted from the provider decimal, never floats. +10. **Client credentials are env-only and requests are bounded** — whitelabel API credentials come from env, all calls carry an explicit timeout, HTTPS base URLs only, and upstream failures surface as generic 502s without echoing provider response bodies. +11. **KYB submission is a guarded stub** — `submitKybData` throws 501 until the whitelabel KYB mechanism is contractually settled (deferred-decisions registry T3); no speculative identity-data path exists. + +## Keeper + +The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox → mint watcher → per-account conversion executor → dormancy gate) holds signing keys and submits transactions; its invariants: + +1. **Three-way key separation** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`/`swapAndForward()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can move funds: `swapAndForward` only executes the contract-constrained oracle-checked swap to the client's own `destination`; `setGuardianPaused` is protective-only by contract invariant; the attestor signs the fixed link statement. All three are env-only and never logged. +2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. +3. **Execution record before send** — a `monerium_conversion_executions` row (status `pending`, `eureInRaw = min(balance, perSwapCap)`, snapshot destination) is durably committed BEFORE any transaction is broadcast, and the tx hash is recorded immediately after send. A crash therefore always leaves an auditable pending row, never an untracked on-chain swap; leftover pendings are resolved next cycle via receipt lookup (finalize) or declared failed (no hash: never sent; stale hash: timed out). +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 snapshot-based and idempotent (R04)** — on confirmation, unallocated minted deposits with mint block ≤ execution block are selected oldest-first up to `eureInRaw`, USDC attribution is pro-rata by `amount_raw` against `eureInRaw` with floor division and remainder to the largest deposit (unit-tested), and `allocated_execution_id` links them. Mint identity is the `(chain_id, tx_hash, log_index)` partial unique index, so watcher re-scans after a crash cannot double-record; non-Monerium EURe inflows become `unattr:`-prefixed deposit rows (R09) and are never presented as customer deposits. +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). + +## 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/runbooks/monerium-b2b-incident.md`). +2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on the pinned EURe→EURC→USDC path at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline). Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. +3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal). +4. **Association monitor (S1 detective control)** — per active account, re-reads the profile's linked addresses (`GET /addresses?profile=`) and the partner-context IBAN list (`GET /ibans`) and 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)** — re-reads per-clone config and bytecode. `destination`/`fallbackAddress` drift is owner-authorized by construction (`onlyFallback` in the contract): it is reconciled into the DB (with a `configVersion` bump) and logged at warn, never alarmed. `feeBps` drift (immutable post-init), a clone whose bytecode is not the EIP-1167 proxy of the factory's implementation, or a missing `isForwarder` registration are error-level should-be-impossible states. Mirrors the standalone manifest verifier (`contracts/monerium-forwarder/script/verify-manifest.ts`), which is documented as consistency evidence, not a trust root (R01). + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| **Webhook spoofing** | Attacker posts fabricated order events to `/v1/monerium-b2b/webhook` to invent or advance deposits | HMAC-SHA256 over raw bytes with constant-time compare; 401 before any persistence; 503 (no acceptance) if the secret is unconfigured | +| **Webhook replay / duplicate delivery** | A captured valid delivery is replayed to double-count a deposit | Durable inbox dedup on unique `event_id` (`ON CONFLICT DO NOTHING`); forward-only transitions make a replayed older state a no-op | +| **Out-of-order events regress state** | A delayed `pending` event arrives after `minted` | Forward-only transition lattice; regressions logged and dropped | +| **Attestor key leak** | Attacker obtains `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` | Blast radius is bounded by design: the key can only produce link attestations for the fixed message, never move funds (contract-side invariant); rotate key + re-deploy forwarders with new ATTESTOR immutable | +| **Attestor signing oracle abuse** | Backend is tricked into signing an arbitrary hash with the attestor key | `signLinkAttestation` derives the hash internally from the fixed LINK_MESSAGE and the forwarder address parameter; no caller-supplied hash is ever signed | +| **Concurrent processors corrupt attribution** | Two instances process events for one account simultaneously | Transaction-scoped Postgres advisory lock per forwarder address serializes all per-account writes | +| **Lost webhook between receipt and processing** | Process crashes after 200 but before the deposit write | Insert-before-200 durable inbox; unprocessed rows are retried on the next run | +| **Poison inbox row blocks processing** | A malformed payload throws forever | Non-order/unrecognized payloads are marked processed and skipped; genuine failures are logged per-row and do not block other rows | +| **API credential compromise** | Whitelabel client id/secret leak | Env-only storage; sandbox credentials are segregated from production (`MONERIUM_B2B_*` set is distinct from legacy `MONERIUM_*`); rotate at Monerium | +| **Provider unavailability** | Monerium API down | Client calls have explicit timeouts and surface 502; webhook inbox is unaffected (processing is local) | + +## Audit Checklist + +- [ ] `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY`, `MONERIUM_B2B_CLIENT_SECRET`, `MONERIUM_B2B_WEBHOOK_SECRET` loaded from env only; grep confirms no logging of their values +- [ ] Attestor signs only the bound link hash (`attestor.ts` has no arbitrary-hash signing entry point) +- [ ] `attestor.test.ts` pins the signature layout against `VortexForwarder.isValidSignature` (65 bytes, v in 27/28, low-s, bound to forwarder address) +- [ ] Webhook HMAC verified over raw captured bytes (`config/express.ts` verify hook), constant-time compare +- [ ] Inbox insert (`ON CONFLICT DO NOTHING` on `event_id`) happens before the 200 response in `monerium-b2b.controller.ts` +- [ ] Forward-only transition guard covers all four statuses; regressive events are dropped, not applied +- [ ] All deposit writes run under `pg_advisory_xact_lock` keyed by lower-cased forwarder address +- [ ] `monerium_order_id` unique constraint present; mint-log partial unique index present (migration 051) +- [ ] `submitKybData` still returns 501 unless registry item T3 has been resolved and this spec updated +- [ ] HTTPS enforced for provider base URLs; timeouts configured on every provider call +- [ ] Sandbox-verification TODOs resolved before production: exact `webhook-signature` digest encoding, delivery id field, upstream order-state vocabulary, EIP-191 vs raw link-hash variant (registry T4) +- [ ] Keeper, guardian, and attestor private keys are three distinct keys in production; none logged +- [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) +- [ ] Conversion execution rows are created before broadcast and every terminal row has status confirmed/failed with a cause; R04 allocation math covered by `conversion-executor.test.ts` +- [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 owner-authorized config reconciliation; quote-impact, stranding, association-diff and drift classification covered by `monitoring.test.ts` +- [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; owner-authorized config changes (R07) are warn-level reconciliations, never incidents diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 082349751..47e97e646 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -1,5 +1,7 @@ # Monerium Integration +> **Superseded for the B2B onramp:** the whitelabel/attestor/webhook integration is specified in [monerium-b2b.md](./monerium-b2b.md); this file covers only the legacy consumer OAuth onboarding flow. + ## What This Does The backend provides authenticated Monerium OAuth authorization-code endpoints for individual KYC and business KYB. It generates OAuth state and PKCE material server-side, exchanges codes directly with Monerium, keeps access and rotating refresh tokens only in backend memory, reads the authenticated Monerium context and API-v2 profile, and mirrors only normalized verification metadata into `provider_customers` and `kyc_cases`. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 62c24b176..12ab879f9 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -39,6 +39,7 @@ This directory contains the security specification for the Vortex cross-border p | BRLA | `05-integrations/brla.md` | BRLA anchor for BRL on/off-ramp | | Mykobo | `05-integrations/mykobo.md` | Mykobo EUR on/off-ramp on Base (currently registration-gated) | | Monerium | `05-integrations/monerium.md` | Server-side OAuth KYC/KYB and verification status mirroring | +| Monerium B2B | `05-integrations/monerium-b2b.md` | Whitelabel onramp: attestor address linking, HMAC webhook + durable inbox, forward-only deposits | | Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | | Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | | FastForex | `05-integrations/fastforex.md` | Fiat forex price provider used by quote/conversion math | diff --git a/package.json b/package.json index 5a14dd338..be955c369 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "build:frontend": "bun run --cwd apps/frontend build", "build:sdk": "bun run --cwd packages/sdk build", "build:shared": "bun run --cwd packages/shared build", + "compile:contracts:monerium-forwarder": "bun run --cwd contracts/monerium-forwarder compile", "compile:contracts:relayer": "bun run --cwd contracts/relayer compile", "dev": "bun run --cwd packages/shared dev & bun run --cwd apps/api dev & bun run --cwd apps/frontend dev & wait", "dev:backend": "bun run --cwd apps/api dev", @@ -126,6 +127,7 @@ "serve:frontend": "bun run --cwd apps/frontend preview", "test": "bun run test:shared && bun run test:kyc && bun run test:sdk && bun run test:rebalancer && bun run test:api && bun run test:frontend", "test:api": "cd apps/api && bun test", + "test:contracts:monerium-forwarder": "bun run --cwd contracts/monerium-forwarder test", "test:contracts:relayer": "bun run --cwd contracts/relayer test", "test:coverage": "bun run --cwd packages/shared test:coverage && bun run --cwd packages/sdk test:coverage && bun run --cwd apps/rebalancer test:coverage && bun run --cwd apps/api test:coverage && bun run --cwd apps/frontend test:coverage && bun scripts/coverage-report.ts", "test:coverage:html": "bun scripts/coverage-report.ts --html coverage/index.html && open coverage/index.html",