Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
68e24b4
Create prd doc for Monerium EUR USDC onramp
ebma Jul 13, 2026
0f2462e
Add review
ebma Jul 13, 2026
0069fdc
Revise PRD doc
ebma Jul 13, 2026
7323ac5
Add re-review
ebma Jul 13, 2026
7d0ab77
Add B2B zero-touch variant doc
ebma Jul 14, 2026
7f4a12c
Lock B2B scope: implementation plan, deferred-decisions registry, var…
ebma Jul 17, 2026
77c4be7
Add monerium-forwarder Foundry project: attestor-linked forwarder + f…
ebma Jul 17, 2026
be91776
Document forge-std submodule init in READMEs
ebma Jul 17, 2026
21648a6
Merge remote-tracking branch 'origin/staging' into vortex-monerium-v2
ebma Jul 17, 2026
0d9e900
Forwarder tests: mainnet-fork suite, invariant suite, reentrancy + un…
ebma Jul 17, 2026
9b8094e
Monerium B2B backend: persistent account/deposit/execution models + w…
ebma Jul 17, 2026
eff320f
Fix review F1: arm stranding marker against immutable floor; add code…
ebma Jul 17, 2026
5b4f228
Monerium B2B backend: whitelabel client, attestor signer, HMAC webhoo…
ebma Jul 17, 2026
8142d0e
Monerium B2B keeper: mint watcher, conversion executor with R04 alloc…
ebma Jul 17, 2026
8d1b42b
G0 spike results: EUR/USD weekend gaps up to 48h, block-pinned liquid…
ebma Jul 17, 2026
3227bae
G0 spike: sandbox EIP-1271 attestor link VALIDATED (eip191 variant), …
ebma Jul 17, 2026
b575a74
Review-r1 dispositions: EIP-191-only + chainid binding (sandbox re-va…
ebma Jul 17, 2026
7b28b46
Registry: record hardened-binding re-validation artifacts
ebma Jul 17, 2026
3177984
Ops deliverables: manifest generator/verifier, monitoring pass, runbo…
ebma Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions apps/api/src/api/controllers/monerium-b2b.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
}
};
7 changes: 7 additions & 0 deletions apps/api/src/api/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/api/routes/v1/monerium-b2b.route.ts
Original file line number Diff line number Diff line change
@@ -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;
76 changes: 76 additions & 0 deletions apps/api/src/api/services/monerium-b2b/attestor.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
});
71 changes: 71 additions & 0 deletions apps/api/src/api/services/monerium-b2b/attestor.ts
Original file line number Diff line number Diff line change
@@ -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<LinkAttestation> {
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)
};
}
Loading
Loading