Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 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
2c11b5f
docs(repo): add a high-level Monerium B2B flow overview with open que…
ebma Sep 29, 2026
d79eb86
docs(repo): lower the swap minimum to 1 EUR and the chunk cap to 10k EUR
ebma Sep 29, 2026
34aaeab
docs(repo): make the Monerium B2B penny test optional
ebma Sep 29, 2026
a5e0802
Merge branch 'staging' of https://github.com/pendulum-chain/vortex in…
ebma Sep 29, 2026
8352e93
docs(repo): answer Monerium flow questions and propose per-client ref…
ebma Sep 29, 2026
cc52616
docs(repo): record Monerium's answers in the B2B flow overview
ebma Sep 30, 2026
c3cc88f
docs(repo): add SulPayments' lifecycle and API-first requirements to …
ebma Sep 30, 2026
988eb1c
docs(repo): record the Monerium call outcomes in the B2B flow overview
ebma Oct 1, 2026
776d357
feat(api): add lifecycle snapshot webhooks and a manager account list
ebma Oct 1, 2026
dd5bfc8
docs(repo): mark the B2B lifecycle events and account list as built
ebma Oct 1, 2026
9ec0b91
feat(repo): fix each forwarder's recovery address at deployment
ebma Oct 1, 2026
19427f7
feat(api): run Monerium B2B refunds from per-client derived wallets
ebma Oct 1, 2026
3ae8931
docs(repo): document per-client refund wallets for the B2B onramp
ebma Oct 1, 2026
b0f72f1
docs(repo): record the KYB and sandbox decisions in the B2B flow over…
ebma Oct 1, 2026
278da4a
Merge branch 'staging' of https://github.com/pendulum-chain/vortex in…
ebma Oct 5, 2026
132b67c
Merge remote-tracking branch 'origin/staging' into feat/monerium-forw…
ebma Oct 5, 2026
ad614e7
Merge remote-tracking branch 'origin/staging' into feat/monerium-forw…
ebma Oct 5, 2026
0bbd78a
Merge remote-tracking branch 'origin/staging' into feat/monerium-forw…
ebma Oct 5, 2026
998d5fc
docs(api): snapshot the Monerium B2B routes this branch mounts
ebma Oct 5, 2026
69df0e9
docs(api): remove the accepted Monerium settlement proposal
ebma Oct 5, 2026
2906510
docs(api): align the fork exercise with the ADR's swap limits
ebma Oct 5, 2026
a7f332b
refactor(api): fold the branch's one-column Monerium migrations
ebma Oct 5, 2026
d20ef33
refactor(api): drop the forwarder ABI's undecoded event entries
ebma Oct 5, 2026
2fccbb3
refactor(api): parse the Coinbase top of book once
ebma Oct 5, 2026
c0af16e
refactor(api): reuse setDepositStatus in the admin deposit-status end…
ebma Oct 5, 2026
141ceca
refactor(api): drop settlement and pricing fields only tests read
ebma Oct 5, 2026
b17a748
refactor(api): build the float wallet client per call
ebma Oct 5, 2026
a5e7c50
refactor(api): share the executor's settling statuses and receipt hel…
ebma Oct 5, 2026
3f755d8
refactor(api): slice the two reversible route layouts directly
ebma Oct 5, 2026
cc72882
refactor(repo): check the forwarder's batch age without inline assembly
ebma Oct 5, 2026
57b58d7
refactor(repo): pay a vault subsidy only to the calling clone
ebma Oct 5, 2026
ff53ebd
fix(api): wait for the refund transfer that was just sent
ebma Oct 5, 2026
c610f61
Merge remote-tracking branch 'origin/staging' into feat/monerium-forw…
ebma Oct 6, 2026
101adfa
docs(api): list every deposit event and register webhooks by API
ebma Oct 6, 2026
dcfc36f
docs(api): add the Sepolia sandbox bring-up to the B2B runbook
ebma Oct 6, 2026
a5b174a
chore(repo): drop the July link-test Sepolia manifest
ebma Oct 6, 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
149 changes: 132 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,19 +5,23 @@ 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";
import { createTestUser } from "../../../test-utils/factories";
import moneriumB2bRoutes from "../../routes/v1/admin/monerium-b2b.route";
import { forwarderConfigMismatch } from "../../services/monerium-b2b/account-provisioning";
import { refundAccountFor } from "../../services/monerium-b2b/refund-wallet";

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";
const FACTORY = "0x4444444444444444444444444444444444444444";

describe("monerium b2b account mapping admin route", () => {
Expand Down Expand Up @@ -68,7 +72,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 +125,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 +152,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 +192,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 @@ -203,20 +204,51 @@ describe("monerium b2b account mapping admin route", () => {
});

it("compares submitted account data against the deployed clone config", () => {
const refundWallet = "0x9999999999999999999999999999999999999999";
const expected = {
destination: DESTINATION.toLowerCase(),
factory: FACTORY.toLowerCase(),
fallbackAddress: FALLBACK.toLowerCase(),
feeBps: 0
floorPpm: 1500,
recoveryAddress: refundWallet,
targetPpm: 1250
};
const matching = {
destination: DESTINATION,
factory: FACTORY,
floorPpm: 1500,
isForwarder: true,
recoveryAddress: refundWallet,
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, recoveryAddress: "0x7777777777777777777777777777777777777777" })
).toContain("refund wallet");
expect(forwarderConfigMismatch(expected, { ...matching, targetPpm: 1_000 })).toContain("targetPpm");
expect(forwarderConfigMismatch(expected, { ...matching, floorPpm: 2_000 })).toContain("floorPpm");
});

it("returns a client's derived refund wallet for deploying its forwarder", async () => {
const savedSeed = config.moneriumB2b.refundSeed;
config.moneriumB2b.refundSeed = `0x${"11".repeat(32)}`;
try {
const profileId = "0B8E7C2A-8F4E-4D43-9F2B-2F9F3C1D5A6E";
const response = await fetch(`${baseUrl}/refund-address?moneriumProfileId=${profileId}`, { headers: ADMIN_HEADERS });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
moneriumProfileId: profileId.toLowerCase(),
refundAddress: refundAccountFor(profileId.toLowerCase()).address
});
expect((await fetch(`${baseUrl}/refund-address?moneriumProfileId=nope`, { headers: ADMIN_HEADERS })).status).toBe(400);
} finally {
config.moneriumB2b.refundSeed = savedSeed;
}
});

it("rejects invalid input and unknown managers", async () => {
Expand All @@ -225,10 +257,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 +324,88 @@ 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 badReason = await fetch(`${baseUrl}/deposits/${deposit.id}/recover`, {
body: JSON.stringify({ reason: "because" }),
headers: ADMIN_HEADERS,
method: "POST"
});
expect(badReason.status).toBe(400);

const marked = await fetch(`${baseUrl}/deposits/${deposit.id}/recover`, {
body: JSON.stringify({ reason: "compliance" }),
headers: ADMIN_HEADERS,
method: "POST"
});
expect(marked.status).toBe(200);
expect(await marked.json()).toMatchObject({ deposit: { depositId: deposit.id, status: "recovering" } });
const markedRow = await MoneriumFiatDeposit.findByPk(deposit.id);
expect(markedRow?.status).toBe(MoneriumFiatDepositStatus.Recovering);
expect(markedRow?.refundReason).toBe("compliance");
expect(markedRow?.refundStartedAt).not.toBeNull();

// 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 refunded.json()).toEqual({ deposit: { depositId: deposit.id, status: "refunded" } });
expect((await patchStatus(deposit.id, "refunded")).status).toBe(200);
const reopened = await patchStatus(deposit.id, "recovering");
expect(reopened.status).toBe(409);
expect(await reopened.json()).toMatchObject({
error: {
code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION",
message: "Monerium deposit cannot transition from refunded to recovering"
}
});
expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Refunded);
expect((await patchStatus(crypto.randomUUID(), "refunded")).status).toBe(404);

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
Loading
Loading