Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
3cad47f
docs(repo): add the forwarder fee and subsidy implementation plan
ebma Sep 15, 2026
1b11b3d
feat(repo): add the shared subsidy vault for forwarder swaps
ebma Sep 15, 2026
f0bbeb0
feat(repo): let the guardian whitelist forwarder swap routes
ebma Sep 15, 2026
57299e8
feat(repo): price forwarder swaps against a reference with fee bands …
ebma Sep 15, 2026
429fa4d
test(repo): apply the decided 7 day sweep delay to the contract configs
ebma Sep 15, 2026
d61c92c
chore(repo): teach the deployment manifest the fee policy, vault and …
ebma Sep 15, 2026
e31996f
style(repo): format the forwarder contracts and tests
ebma Sep 15, 2026
7769a5d
feat(api): mirror the forwarder fee policy in ppm on monerium accounts
ebma Sep 15, 2026
2966dcf
feat(api): price keeper swaps against the coinbase reference with rou…
ebma Sep 15, 2026
cd29bf8
feat(api): expose execution pricing on deposit conversions
ebma Sep 15, 2026
e130029
feat(api): quote every forwarder route and monitor the subsidy vault
ebma Sep 15, 2026
8f940a4
docs(api): sync the monerium b2b security spec with reference-priced …
ebma Sep 15, 2026
c33ea2a
docs(repo): record the fee, subsidy, route and sweep decisions in adr…
ebma Sep 15, 2026
e78e98c
docs(repo): describe reference-priced swaps in the architecture and r…
ebma Sep 15, 2026
edc687e
docs(repo): fold the fee and subsidy plan into the maintained docs
ebma Sep 15, 2026
b2301f5
feat(api): price swaps against a five-minute coinbase vwap instead of…
ebma Sep 15, 2026
fce73c2
docs(repo): describe the vwap reference rate
ebma Sep 15, 2026
6dd3a35
docs(api): refresh wire-contract snapshot for execution pricing
ebma Sep 16, 2026
92083ac
fix(repo): verify subsidy delivery in the forwarder settlement
ebma Sep 16, 2026
da3d97c
test(repo): pin the fee-branch oracle floor with a skewed reference
ebma Sep 16, 2026
4f3ba9d
docs(repo): state the effective reference margin below chainlink
ebma Sep 16, 2026
7006797
fix(api): describe the depth check as a subsidy signal
ebma Sep 16, 2026
7264b04
fix(repo): diff manifest route arrays by index
ebma Sep 16, 2026
271753e
test(api): cover pricePlannedSwap and finalizeExecution
ebma Sep 16, 2026
96b34ed
docs(api): disclose the reference window fallback
ebma Sep 16, 2026
f8413e5
docs(repo): tighten monerium b2b ops wording
ebma Sep 16, 2026
4941e0d
Merge remote-tracking branch 'origin/staging' into feat/monerium-forw…
ebma Sep 17, 2026
a4e93bc
docs(repo): propose whole-deposit settlement and refund recovery for …
ebma Sep 17, 2026
0cb326b
fix(api): read the reference from Coinbase EURC-USDC and monitor the …
ebma Sep 17, 2026
145d23a
feat(repo): accumulate swaps on the forwarder and add a delay-gated r…
ebma Sep 17, 2026
c619a30
feat(api): convert deposits in chunks, forward them whole, and mark r…
ebma Sep 17, 2026
4ae14f6
docs(repo): document whole-deposit settlement and the refund path
ebma Sep 17, 2026
49c309f
feat(api): record each deposit's mint time and payer for the refund path
ebma Sep 17, 2026
9643119
feat(api): automate the refund of deposits that miss the promised window
ebma Sep 17, 2026
f0b901b
docs(repo): describe the automated refund path
ebma Sep 17, 2026
953c736
feat(api): notify managers when a deposit is refunded
ebma Sep 17, 2026
82b1b9a
docs(repo): describe the DEPOSIT_RETURNED event in the ADR, spec and …
ebma Sep 17, 2026
154ca1f
feat(repo): cap each swap's subsidy at a keeper-supplied maximum
ebma Sep 18, 2026
d11737a
feat(api): escalate the subsidy tier with a chunk's waiting time
ebma Sep 18, 2026
1571eb1
feat(api): price swaps against the Coinbase bid/ask midpoint
ebma Sep 18, 2026
c806b38
docs(repo): record the subsidy ladder, the per-swap cap and the spot …
ebma Sep 18, 2026
d370181
docs(repo): record the spot drift replay behind the 60 bps floor
ebma Sep 18, 2026
25dd2fe
feat(repo): settle a low reference to the Chainlink floor instead of …
ebma Sep 18, 2026
29a89b3
feat(api): project the Chainlink-bounded floor and target in the keeper
ebma Sep 18, 2026
85b4c71
docs(repo): describe the Chainlink-bounded floor and target
ebma Sep 18, 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
101 changes: 84 additions & 17 deletions apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import KycCase from "../../../models/kycCase.model";
import ManagedProfile from "../../../models/managedProfile.model";
import ManagedProfileManager from "../../../models/managedProfileManager.model";
import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model";
import MoneriumConversionExecution, {
MoneriumConversionExecutionStatus
} from "../../../models/moneriumConversionExecution.model";
import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model";
import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model";
import User from "../../../models/user.model";
import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db";
Expand All @@ -17,7 +21,6 @@ const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type

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

describe("monerium b2b account mapping admin route", () => {
Expand Down Expand Up @@ -68,7 +71,6 @@ describe("monerium b2b account mapping admin route", () => {
contactEmail: "ops@client.example.com",
destination: DESTINATION,
externalSubjectId: "client-1",
fallbackAddress: FALLBACK,
forwarderAddress: FORWARDER,
managerProfileId,
moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e",
Expand Down Expand Up @@ -122,9 +124,9 @@ describe("monerium b2b account mapping admin route", () => {
const row = await MoneriumAccount.findByPk(account.accountId);
expect(row).toMatchObject({
destination: DESTINATION,
fallbackAddress: FALLBACK,
feeBps: 0,
floorPpm: 1500,
forwarderAddress: FORWARDER,
targetPpm: 1250,
vortexProfileId: account.profileId
});
});
Expand All @@ -149,8 +151,6 @@ describe("monerium b2b account mapping admin route", () => {
const managerProfileId = await createManager();
await MoneriumAccount.create({
destination: DESTINATION,
fallbackAddress: FALLBACK,
feeBps: 0,
forwarderAddress: FORWARDER,
profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"
});
Expand Down Expand Up @@ -191,8 +191,8 @@ describe("monerium b2b account mapping admin route", () => {
);
expect(differentSubject.status).toBe(409);

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

expect(await MoneriumAccount.count()).toBe(1);
Expand All @@ -206,17 +206,25 @@ describe("monerium b2b account mapping admin route", () => {
const expected = {
destination: DESTINATION.toLowerCase(),
factory: FACTORY.toLowerCase(),
fallbackAddress: FALLBACK.toLowerCase(),
feeBps: 0
floorPpm: 1500,
targetPpm: 1250
};
const matching = {
destination: DESTINATION,
factory: FACTORY,
floorPpm: 1500,
isForwarder: true,
targetPpm: 1250
};
const matching = { destination: DESTINATION, factory: FACTORY, fallbackAddress: FALLBACK, feeBps: 0, isForwarder: true };

expect(forwarderConfigMismatch(expected, matching)).toBeNull();
expect(forwarderConfigMismatch(expected, { ...matching, factory: FORWARDER })).toContain("trusted factory");
expect(forwarderConfigMismatch(expected, { ...matching, isForwarder: false })).toContain("not a clone");
expect(forwarderConfigMismatch(expected, { ...matching, destination: FALLBACK })).toContain("destination");
expect(forwarderConfigMismatch(expected, { ...matching, fallbackAddress: DESTINATION })).toContain("fallbackAddress");
expect(forwarderConfigMismatch(expected, { ...matching, feeBps: 30 })).toContain("feeBps");
expect(
forwarderConfigMismatch(expected, { ...matching, destination: "0x3333333333333333333333333333333333333333" })
).toContain("destination");
expect(forwarderConfigMismatch(expected, { ...matching, targetPpm: 1_000 })).toContain("targetPpm");
expect(forwarderConfigMismatch(expected, { ...matching, floorPpm: 2_000 })).toContain("floorPpm");
});

it("rejects invalid input and unknown managers", async () => {
Expand All @@ -225,10 +233,11 @@ describe("monerium b2b account mapping admin route", () => {
for (const overrides of [
{ forwarderAddress: "not-an-address" },
{ destination: "0x12345" },
{ fallbackAddress: "" },
{ moneriumProfileId: "not-a-uuid" },
{ feeBps: 3.5 },
{ feeBps: -1 },
{ targetPpm: 3.5 },
{ floorPpm: -1 },
{ floorPpm: 10_001 },
{ floorPpm: 1_000, targetPpm: 1_200 },
{ externalSubjectId: "" },
{ contactEmail: "not-an-email" }
]) {
Expand Down Expand Up @@ -291,6 +300,64 @@ describe("monerium b2b account mapping admin route", () => {
expect((await patchStatus(crypto.randomUUID(), "active")).status).toBe(404);
});

it("marks a settling deposit for recovery and lets an operator close or retry it", async () => {
const managerProfileId = await createManager();
const created = await post(validBody(managerProfileId));
const { account } = (await created.json()) as { account: { accountId: string } };
const deposit = await MoneriumFiatDeposit.create({
accountId: account.accountId,
amountRaw: "100000000000000000000",
blockNumber: 100,
chainId: 11155111,
currency: "eur",
logIndex: 1,
moneriumOrderId: "order-1",
status: MoneriumFiatDepositStatus.Converting,
txHash: "0xmint"
});
const recover = (depositId: string) =>
fetch(`${baseUrl}/deposits/${depositId}/recover`, { headers: ADMIN_HEADERS, method: "POST" });
const patchStatus = (depositId: string, status: unknown) =>
fetch(`${baseUrl}/deposits/${depositId}/status`, {
body: JSON.stringify({ status }),
headers: ADMIN_HEADERS,
method: "PATCH"
});

// A pending keeper transaction must settle first: the amounts to recover depend on it.
const pending = await MoneriumConversionExecution.create({
accountId: account.accountId,
depositId: deposit.id,
destination: DESTINATION,
eureInRaw: "60000000000000000000",
status: MoneriumConversionExecutionStatus.Pending
});
const blocked = await recover(deposit.id);
expect(blocked.status).toBe(409);
expect(await blocked.json()).toMatchObject({ error: { message: expect.stringContaining("pending execution") } });
await pending.update({ status: MoneriumConversionExecutionStatus.Failed });

const marked = await recover(deposit.id);
expect(marked.status).toBe(200);
expect(await marked.json()).toMatchObject({ deposit: { depositId: deposit.id, status: "recovering" } });
expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Recovering);

// Forward-only: a recovering deposit cannot be marked again, but closes or retries.
expect((await recover(deposit.id)).status).toBe(409);
expect((await patchStatus(deposit.id, "forwarded")).status).toBe(400);
const failed = await patchStatus(deposit.id, "recovery_failed");
expect(failed.status).toBe(200);
const retried = await patchStatus(deposit.id, "recovering");
expect(retried.status).toBe(200);
const refunded = await patchStatus(deposit.id, "refunded");
expect(refunded.status).toBe(200);
expect((await patchStatus(deposit.id, "recovering")).status).toBe(409);
expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Refunded);

expect((await recover(crypto.randomUUID())).status).toBe(404);
expect((await recover("not-a-uuid")).status).toBe(400);
});

it("refuses managers not allowed to provision business customers", async () => {
const profile = await createTestUser();
await ManagedProfileManager.create({
Expand Down
130 changes: 121 additions & 9 deletions apps/api/src/api/controllers/admin/moneriumB2b.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { Request, Response } from "express";
import httpStatus from "http-status";
import logger from "../../../config/logger";
import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model";
import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model";
import { ManagedProfileProvisioningError } from "../../services/managed-profile-provisioning.service";
import { MoneriumB2bProvisioningError, provisionMoneriumB2bAccount } from "../../services/monerium-b2b/account-provisioning";
import { markDepositForRecovery } from "../../services/monerium-b2b/conversion-executor";
import { isForwardTransition, withForwarderLock } from "../../services/monerium-b2b/deposit-processor";

const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

Expand All @@ -13,11 +16,11 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi
contactEmail,
destination,
externalSubjectId,
fallbackAddress,
feeBps,
floorPpm,
forwarderAddress,
managerProfileId,
moneriumProfileId
moneriumProfileId,
targetPpm
} = req.body ?? {};
if (
typeof managerProfileId !== "string" ||
Expand All @@ -29,14 +32,14 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi
typeof contactEmail !== "string" ||
typeof forwarderAddress !== "string" ||
typeof destination !== "string" ||
typeof fallbackAddress !== "string" ||
(feeBps !== undefined && typeof feeBps !== "number")
(targetPpm !== undefined && typeof targetPpm !== "number") ||
(floorPpm !== undefined && typeof floorPpm !== "number")
) {
res.status(httpStatus.BAD_REQUEST).json({
error: {
code: "MONERIUM_B2B_INVALID_INPUT",
message:
"managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; feeBps must be a number when present",
"managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, and destination are required; targetPpm and floorPpm must be numbers when present",
status: httpStatus.BAD_REQUEST
}
});
Expand All @@ -47,11 +50,11 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi
contactEmail,
destination,
externalSubjectId,
fallbackAddress,
feeBps,
floorPpm,
forwarderAddress,
managerProfileId,
moneriumProfileId
moneriumProfileId,
targetPpm
});
res.status(result.created ? httpStatus.CREATED : httpStatus.OK).json({ account: result });
} catch (error) {
Expand Down Expand Up @@ -150,3 +153,112 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st
});
}
}

/**
* POST /v1/admin/monerium-b2b/deposits/:depositId/recover — marks a settling deposit for
* the refund path (runbook §2.7). The keeper moves its unconverted EURe and converted
* USDC to the recovery wallet once the clone's batch has been open for RECOVERY_DELAY;
* the bank refund itself follows the runbook until it is automated.
*/
export async function postMoneriumB2bDepositRecovery(req: Request<{ depositId: string }>, res: Response): Promise<void> {
try {
if (!UUID_PATTERN.test(req.params.depositId)) {
res.status(httpStatus.BAD_REQUEST).json({
error: { code: "MONERIUM_B2B_INVALID_INPUT", message: "depositId must be a UUID", status: httpStatus.BAD_REQUEST }
});
return;
}
const refusal = await markDepositForRecovery(req.params.depositId);
if (refusal === "deposit not found") {
res.status(httpStatus.NOT_FOUND).json({
error: { code: "MONERIUM_B2B_DEPOSIT_NOT_FOUND", message: "Monerium deposit not found", status: httpStatus.NOT_FOUND }
});
return;
}
if (refusal) {
res.status(httpStatus.CONFLICT).json({
error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", message: refusal, status: httpStatus.CONFLICT }
});
return;
}
res
.status(httpStatus.OK)
.json({ deposit: { depositId: req.params.depositId, status: MoneriumFiatDepositStatus.Recovering } });
} catch (error) {
logger.error("Error marking Monerium B2B deposit for recovery:", error);
res.status(httpStatus.INTERNAL_SERVER_ERROR).json({
error: {
code: "INTERNAL_SERVER_ERROR",
message: "Failed to mark the deposit for recovery",
status: httpStatus.INTERNAL_SERVER_ERROR
}
});
}
}

const OPERATOR_DEPOSIT_STATUSES: readonly string[] = [
MoneriumFiatDepositStatus.Refunded,
MoneriumFiatDepositStatus.RecoveryFailed,
MoneriumFiatDepositStatus.Recovering
];

/**
* PATCH /v1/admin/monerium-b2b/deposits/:depositId/status — closes or retries a
* recovery by hand: `refunded` once the bank refund went out, `recovery_failed` when it
* cannot, `recovering` to retry a failed one. Forward-only like every deposit transition.
*/
export async function patchMoneriumB2bDepositStatus(req: Request<{ depositId: string }>, res: Response): Promise<void> {
try {
const { status } = req.body ?? {};
if (!UUID_PATTERN.test(req.params.depositId) || typeof status !== "string" || !OPERATOR_DEPOSIT_STATUSES.includes(status)) {
res.status(httpStatus.BAD_REQUEST).json({
error: {
code: "MONERIUM_B2B_INVALID_INPUT",
message: `depositId must be a UUID and status must be one of ${OPERATOR_DEPOSIT_STATUSES.join(", ")}`,
status: httpStatus.BAD_REQUEST
}
});
return;
}
const deposit = await MoneriumFiatDeposit.findByPk(req.params.depositId);
if (!deposit) {
res.status(httpStatus.NOT_FOUND).json({
error: { code: "MONERIUM_B2B_DEPOSIT_NOT_FOUND", message: "Monerium deposit not found", status: httpStatus.NOT_FOUND }
});
return;
}
const account = await MoneriumAccount.findByPk(deposit.accountId);
if (!account) {
res.status(httpStatus.NOT_FOUND).json({
error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND", message: "Monerium account not found", status: httpStatus.NOT_FOUND }
});
return;
}
const targetStatus = status as MoneriumFiatDepositStatus;
const outcome = await withForwarderLock(account.forwarderAddress, async transaction => {
const current = await MoneriumFiatDeposit.findByPk(deposit.id, { transaction });
if (!current) return "missing";
if (targetStatus === current.status) return "same";
if (!isForwardTransition(current.status, targetStatus))
return `Monerium deposit cannot transition from ${current.status} to ${targetStatus}`;
await current.update({ status: targetStatus }, { transaction });
return "updated";
});
if (outcome !== "updated" && outcome !== "same") {
res.status(httpStatus.CONFLICT).json({
error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", message: outcome, status: httpStatus.CONFLICT }
});
return;
}
res.status(httpStatus.OK).json({ deposit: { depositId: deposit.id, status: targetStatus } });
} catch (error) {
logger.error("Error updating Monerium B2B deposit status:", error);
res.status(httpStatus.INTERNAL_SERVER_ERROR).json({
error: {
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update Monerium B2B deposit status",
status: httpStatus.INTERNAL_SERVER_ERROR
}
});
}
}
Loading
Loading