Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 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
4415bc2
chore(repo): merge staging into vortex-monerium-v2
ebma Aug 25, 2026
1cb5ad4
chore(repo): register the monerium-forwarder workspace in the lockfile
ebma Aug 25, 2026
f1c0f8e
fix(api): pass options to monerium migration table drops
ebma Aug 25, 2026
db87335
docs(api): sync api-surface route inventory with monerium-b2b route
ebma Aug 25, 2026
7df538e
feat(api): map monerium b2b accounts to managed profiles
ebma Aug 25, 2026
c6b4436
feat(api): automate monerium b2b onboarding link and iban issuance
ebma Aug 25, 2026
2b793a8
feat(api): add manager read surface for monerium b2b accounts
ebma Aug 25, 2026
9ff3710
feat(shared): add account-scoped deposit webhook event family
ebma Aug 25, 2026
a8c94b7
feat(api): deliver deposit events durably to account managers
ebma Aug 25, 2026
f1f5c80
docs(api): document deposit webhooks and amend the owner invariant
ebma Aug 25, 2026
216ccc3
fix(api): let deposit-event webhooks register without a quote target
ebma Aug 26, 2026
5b218c2
fix(api): give the mykobo backend sole ownership of the monerium keeper
ebma Aug 26, 2026
038cee6
fix(api): make keeper conversions crash-safe and race-free
ebma Aug 26, 2026
10590b7
fix(api): keep webhook-first deposits attributable in the mint watcher
ebma Aug 26, 2026
ae70e1d
fix(api): verify the deployed forwarder before mapping an account
ebma Aug 26, 2026
50d4af3
test(api): cover the review's keeper coverage gaps
ebma Aug 26, 2026
ff2101f
fix(api): harden the review's smaller monerium and outbox findings
ebma Aug 26, 2026
0fc3a67
feat(api): bound retention for the outbox and webhook inbox
ebma Aug 26, 2026
a5bd32d
docs(api): correct the attestor invariant and sync keeper spec claims
ebma Aug 26, 2026
c2210db
docs(api): add the monerium b2b read surface and deposit events to th…
ebma Aug 26, 2026
bb76f75
docs(api): add a recommended-values decision table to the registry
ebma Aug 26, 2026
cf2df08
docs(api): add the monerium b2b onramp architecture overview
ebma Aug 26, 2026
8c68b54
fix(api): attribute deposits larger than the per-swap cap
ebma Aug 26, 2026
ac13fc7
docs(api): register the fee-setter and clone-migration decisions
ebma Aug 26, 2026
6b5d0cc
feat(api): add the guardian fee setter with a 24h increase timelock
ebma Aug 26, 2026
5e94f5e
docs(api): expand the onramp architecture doc with diagrams
ebma Aug 26, 2026
88a66ea
feat(shared): add Monerium white-label client
gianfra-t Aug 25, 2026
0f5a983
docs(shared): document Monerium profile lifecycle
gianfra-t Aug 25, 2026
9a58d13
docs(api): add message-change notice to the G1 approval package
ebma Aug 26, 2026
ff60268
refactor(api): migrate monerium b2b onto the shared white-label client
ebma Aug 26, 2026
e2d6c37
docs(api): refresh the generated openapi types
ebma Aug 26, 2026
91c9463
docs(api): resolve T1 — recovery validates the same ownership message
ebma Aug 26, 2026
980a60e
test(api): apply the decided 52h oracle age to the contract configs
ebma Aug 26, 2026
a8156a7
docs(repo): consolidate monerium b2b docs into the maintained set
ebma Aug 26, 2026
e72d5e6
fix(repo): reset dead-man timer after client sweep
gianfra-t Aug 27, 2026
9924e50
fix(api): recover pending monerium swaps before eligibility checks
gianfra-t Aug 27, 2026
d80096b
fix(repo): classify forwarder fees as guardian mutable
gianfra-t Aug 27, 2026
bfadc6a
chore(repo): exclude forge dependency from biome
gianfra-t Aug 27, 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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This is a Bun monorepo.
| [`packages/kyc`](packages/kyc/) | Provider KYC/KYB state machines shared by the two web apps |
| [`packages/sdk`](packages/sdk/) | Public `@vortexfi/sdk` integration package |
| [`contracts/relayer`](contracts/relayer/) | Token relayer Solidity project |
| [`contracts/monerium-forwarder`](contracts/monerium-forwarder/) | Monerium B2B onramp forwarder Solidity project (Foundry) |

See [`MAP.md`](MAP.md) for detailed wayfinding and [`docs/README.md`](docs/README.md)
for the documentation structure.
Expand All @@ -36,6 +37,11 @@ bun dev
In a fresh Git worktree, run `bun bootstrap:worktree` instead of `bun install`; it also
builds the shared and SDK workspaces required by the apps.

The Foundry-based contracts in `contracts/monerium-forwarder` use a git submodule
(`forge-std`). If you plan to work on those contracts, initialize it once with
`git submodule update --init` (or clone with `git clone --recurse-submodules`).
Everything else in the monorepo works without this step.

The default development command starts the shared package, API, and widget. Run other
surfaces explicitly:

Expand Down Expand Up @@ -68,6 +74,7 @@ bun test:frontend
bun test:e2e
bun test:e2e:dashboard
bun test:contracts:relayer
bun test:contracts:monerium-forwarder
```

The root scripts in [`package.json`](package.json) are the canonical command list.
Expand Down
18 changes: 18 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ ALFREDPAY_API_SECRET=your-alfredpay-api-secret
MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id
MONERIUM_API_URL=https://api.monerium.dev
MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback
# Server-to-server white-label access (shared client; also the Monerium B2B onramp
# credentials). Keep this backend-only.
MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id
MONERIUM_WHITELABEL_CLIENT_SECRET=your-monerium-whitelabel-client-secret

# BRLA / Avenia
# BRLA_BASE_URL=
Expand All @@ -178,6 +182,20 @@ BRLA_PRIVATE_KEY=your-brla-private-key
# ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID= # a KYC submission of that customer
# AVENIA_CONTRACT_SUBACCOUNT_ID= # KYC-approved Avenia sandbox subaccount
# AVENIA_CONTRACT_COMPANY_SUBACCOUNT_ID= # COMPANY sandbox subaccount with >=1 KYB attempt
# MONERIUM_CONTRACT_PROFILE_ID= # approved white-label sandbox profile
# MONERIUM_CONTRACT_ADDRESS= # address linked to that profile
# MONERIUM_CONTRACT_ADDRESS_CHAIN= # e.g. ethereum
# MONERIUM_CONTRACT_IBAN= # IBAN owned by that profile
# MONERIUM_CONTRACT_ORDER_ID= # existing sandbox order
# MONERIUM_CONTRACT_RUN_ADDRESS_FLOW=1
# MONERIUM_CONTRACT_ADDRESS_SIGNATURE= # fresh EOA or combined off-chain EIP-1271 hex bytes
# MONERIUM_CONTRACT_RUN_IBAN_FLOW=1
# MONERIUM_CONTRACT_RUN_ORDER_FLOW=1
# MONERIUM_CONTRACT_ORDER_REQUEST_JSON= # freshly signed complete POST /orders body
# MONERIUM_CONTRACT_RUN_FILE_UPLOAD=1
# MONERIUM_CONTRACT_RUN_WEBHOOK_FLOW=1
# MONERIUM_CONTRACT_WEBHOOK_URL= # must acknowledge subscription.created with HTTP 200
# MONERIUM_CONTRACT_WEBHOOK_SECRET= # whsec_ + base64-encoded 24-64 random bytes

# Local manual flow testing only. Replaces BRLA and AlfredPay mints with an ephemeral
# balance wait and pauses offramps before the anchor transfer. Development only.
Expand Down
278 changes: 278 additions & 0 deletions apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import express from "express";
import KycCase from "../../../models/kycCase.model";
import ManagedProfile from "../../../models/managedProfile.model";
import ManagedProfileManager from "../../../models/managedProfileManager.model";
import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model";
import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model";
import User from "../../../models/user.model";
import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db";
import { createTestUser } from "../../../test-utils/factories";
import moneriumB2bRoutes from "../../routes/v1/admin/monerium-b2b.route";
import { forwarderConfigMismatch } from "../../services/monerium-b2b/account-provisioning";

const BASE_PATH = "/v1/admin/monerium-b2b";
const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type": "application/json" };

const FORWARDER = "0x1111111111111111111111111111111111111111";
const DESTINATION = "0x2222222222222222222222222222222222222222";
const FALLBACK = "0x3333333333333333333333333333333333333333";

describe("monerium b2b account mapping admin route", () => {
let server: ReturnType<typeof express.application.listen>;
let baseUrl: string;

beforeAll(async () => {
await setupTestDatabase();

const app = express();
app.use(express.json());
app.use(BASE_PATH, moneriumB2bRoutes);
server = app.listen(0);
const address = server.address();
if (!address || typeof address === "string") throw new Error("Could not bind test server");
baseUrl = `http://127.0.0.1:${address.port}${BASE_PATH}`;
});

afterAll(() => {
server?.close();
});

beforeEach(async () => {
await resetTestDatabase();
});

async function createManager(): Promise<string> {
const profile = await createTestUser();
await ManagedProfileManager.create({
allowedCorridors: ["EU"],
allowedCustomerTypes: ["business"],
isActive: true,
profileId: profile.id
});
return profile.id;
}

function post(body: unknown, headers: Record<string, string> = ADMIN_HEADERS) {
return fetch(`${baseUrl}/accounts`, { body: JSON.stringify(body), headers, method: "POST" });
}

function validBody(managerProfileId: string, overrides: Record<string, unknown> = {}) {
return {
contactEmail: "ops@client.example.com",
destination: DESTINATION,
externalSubjectId: "client-1",
fallbackAddress: FALLBACK,
forwarderAddress: FORWARDER,
managerProfileId,
moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e",
...overrides
};
}

it("requires admin authentication", async () => {
const response = await post(validBody(crypto.randomUUID()), { "Content-Type": "application/json" });
expect(response.status).toBe(401);
});

it("provisions the managed child, KYB mirror, and account", async () => {
const managerProfileId = await createManager();

const response = await post(validBody(managerProfileId));
expect(response.status).toBe(201);
const { account } = await response.json();
expect(account).toMatchObject({
accountStatus: MoneriumAccountStatus.Onboarding,
created: true,
iban: null,
moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"
});

const child = await User.findByPk(account.profileId);
expect(child?.kind).toBe("managed");
expect(child?.email).toBeNull();

const relationship = await ManagedProfile.findOne({ where: { profileId: account.profileId } });
expect(relationship).toMatchObject({
creationSource: "vortex",
externalSubjectId: "client-1",
managerProfileId,
status: "active"
});

const customer = await ProviderCustomer.findOne({ where: { customerEntityId: account.customerEntityId } });
expect(customer).toMatchObject({
customerType: "business",
provider: "monerium",
providerCustomerId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e",
rail: "eur",
status: VerificationStatus.Approved
});

const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } });
expect(kycCase).toMatchObject({ status: VerificationStatus.Approved, type: "kyb" });
expect(kycCase?.approvedAt).not.toBeNull();

const row = await MoneriumAccount.findByPk(account.accountId);
expect(row).toMatchObject({
destination: DESTINATION,
fallbackAddress: FALLBACK,
feeBps: 0,
forwarderAddress: FORWARDER,
vortexProfileId: account.profileId
});
});

it("is idempotent for an identical replay", async () => {
const managerProfileId = await createManager();

const first = await post(validBody(managerProfileId));
expect(first.status).toBe(201);
const replay = await post(validBody(managerProfileId));
expect(replay.status).toBe(200);
const { account } = await replay.json();
expect(account.created).toBe(false);

expect(await MoneriumAccount.count()).toBe(1);
expect(await ManagedProfile.count()).toBe(1);
expect(await ProviderCustomer.count()).toBe(1);
expect(await KycCase.count()).toBe(1);
});

it("adopts a pre-mapping account row that matches the deployed forwarder", async () => {
const managerProfileId = await createManager();
await MoneriumAccount.create({
destination: DESTINATION,
fallbackAddress: FALLBACK,
feeBps: 0,
forwarderAddress: FORWARDER,
profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"
});

const response = await post(validBody(managerProfileId));
expect(response.status).toBe(200);
const { account } = await response.json();
expect(account.created).toBe(false);

const row = await MoneriumAccount.findByPk(account.accountId);
expect(row?.vortexProfileId).toBe(account.profileId);
});

it("rejects a divergent replay instead of overwriting", async () => {
const managerProfileId = await createManager();
expect((await post(validBody(managerProfileId))).status).toBe(201);

// Same Monerium profile, different forwarder.
const differentForwarder = await post(
validBody(managerProfileId, { forwarderAddress: "0x4444444444444444444444444444444444444444" })
);
expect(differentForwarder.status).toBe(409);
expect(await differentForwarder.json()).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_CONFLICT" } });

// Same child, different Monerium profile.
const differentMonerium = await post(
validBody(managerProfileId, { moneriumProfileId: "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f" })
);
expect(differentMonerium.status).toBe(409);

// Different subject claiming the same Monerium profile.
const differentSubject = await post(
validBody(managerProfileId, {
contactEmail: "other@client.example.com",
externalSubjectId: "client-2",
forwarderAddress: "0x5555555555555555555555555555555555555555"
})
);
expect(differentSubject.status).toBe(409);

// Same everything, different feeBps: divergence, not a silent idempotent replay.
const differentFee = await post(validBody(managerProfileId, { feeBps: 25 }));
expect(differentFee.status).toBe(409);

expect(await MoneriumAccount.count()).toBe(1);
});

it("compares submitted account data against the deployed clone config", () => {
const expected = { destination: DESTINATION.toLowerCase(), fallbackAddress: FALLBACK.toLowerCase(), feeBps: 0 };
const matching = { destination: DESTINATION, fallbackAddress: FALLBACK, feeBps: 0, isForwarder: true };

expect(forwarderConfigMismatch(expected, matching)).toBeNull();
expect(forwarderConfigMismatch(expected, { ...matching, isForwarder: false })).toContain("not a clone");
expect(forwarderConfigMismatch(expected, { ...matching, destination: FALLBACK })).toContain("destination");
expect(forwarderConfigMismatch(expected, { ...matching, fallbackAddress: DESTINATION })).toContain("fallbackAddress");
expect(forwarderConfigMismatch(expected, { ...matching, feeBps: 30 })).toContain("feeBps");
});

it("rejects invalid input and unknown managers", async () => {
const managerProfileId = await createManager();

for (const overrides of [
{ forwarderAddress: "not-an-address" },
{ destination: "0x12345" },
{ fallbackAddress: "" },
{ moneriumProfileId: "not-a-uuid" },
{ feeBps: 3.5 },
{ feeBps: -1 },
{ externalSubjectId: "" },
{ contactEmail: "not-an-email" }
]) {
const response = await post(validBody(managerProfileId, overrides));
expect(response.status).toBe(400);
}
expect(await MoneriumAccount.count()).toBe(0);

const unknownManager = await post(validBody(crypto.randomUUID()));
expect(unknownManager.status).toBe(404);
expect(await unknownManager.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_NOT_FOUND" } });
});

it("updates account status with the IBAN activation guard", async () => {
const managerProfileId = await createManager();
const created = await post(validBody(managerProfileId));
const { account } = await created.json();

function patchStatus(accountId: string, status: unknown) {
return fetch(`${baseUrl}/accounts/${accountId}/status`, {
body: JSON.stringify({ status }),
headers: ADMIN_HEADERS,
method: "PATCH"
});
}

// No IBAN yet: activation is refused, other transitions work.
const premature = await patchStatus(account.accountId, "active");
expect(premature.status).toBe(409);
expect(await premature.json()).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_NOT_READY" } });

await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516" }, { where: { id: account.accountId } });
const activated = await patchStatus(account.accountId, "active");
expect(activated.status).toBe(200);
expect(await activated.json()).toMatchObject({ account: { accountStatus: "active" } });

const suspended = await patchStatus(account.accountId, "suspended");
expect(suspended.status).toBe(200);

// Re-activation after a suspension keeps the IBAN guard satisfied.
const reactivated = await patchStatus(account.accountId, "active");
expect(reactivated.status).toBe(200);
expect(await reactivated.json()).toMatchObject({ account: { accountStatus: "active" } });

expect((await patchStatus(account.accountId, "nonsense")).status).toBe(400);
expect((await patchStatus(crypto.randomUUID(), "active")).status).toBe(404);
});

it("refuses managers not allowed to provision business customers", async () => {
const profile = await createTestUser();
await ManagedProfileManager.create({
allowedCorridors: ["BR"],
allowedCustomerTypes: ["individual"],
isActive: true,
profileId: profile.id
});

const response = await post(validBody(profile.id));
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_INVALID_INPUT" } });
expect(await MoneriumAccount.count()).toBe(0);
});
});
Loading
Loading