Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 27 additions & 5 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,18 @@ describe("planAction", () => {

it("does nothing for a sub-minimum remainder or an empty queue", () => {
expect(planAction([withSwaps(deposit("a", MoneriumFiatDepositStatus.Minted, 10n * EUR), [])], base)).toMatchObject({
kind: "none",
reason: expect.stringContaining("below the minimum swap")
kind: "none"
});
expect(planAction([], base)).toMatchObject({ kind: "none" });
});

it("never lets a deposit below the minimum hold back a younger one", () => {
// The contract arms no batch below MIN_SWAP_FLOOR, so the small payment may never reach
// `recover`: waiting for it would stall every later payment of the account.
const small = withSwaps(deposit("small", MoneriumFiatDepositStatus.Minted, 10n * EUR), []);
const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 100n * EUR), []);
expect(planAction([small, young], base)).toMatchObject({ amountIn: 100n * EUR, deposit: young.deposit, kind: "swap" });
});
});

describe("conversionAmountsFromSwapEvent", () => {
Expand Down Expand Up @@ -965,13 +972,15 @@ describe("runConversionExecutor activation gate", () => {
await runConversionExecutor(accountId);
await deposit.reload();
expect(deposit.waitingReason).toBe("account_not_active");
expect(deposit.waitingSince).toBeInstanceOf(Date);
const activationWait = deposit.waitingSince;
expect(activationWait).toBeInstanceOf(Date);

await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } });
await runConversionExecutor(accountId);
await deposit.reload();
expect(deposit.waitingReason).toBeNull();
expect(deposit.waitingSince).toBeNull();
// The activation wait is over; what holds this small deposit now is the minimum swap.
expect(deposit.waitingReason).toBe("below_minimum");
expect(deposit.waitingSince?.getTime()).not.toBe(activationWait?.getTime());
});

it("clears the reason on every queued deposit once the account converts, not only the next one", async () => {
Expand All @@ -988,6 +997,19 @@ describe("runConversionExecutor activation gate", () => {
expect((await queued.reload()).waitingReason).toBeNull();
});

it("tells the partner a payment below the minimum swap waits, and converts the next payment meanwhile", async () => {
const { accountId, deposits } = await accountWithDeposit(null, [10n, 100n]);
await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } });

await runConversionExecutor(accountId);
const [small, next] = deposits;
await small.reload();
expect(small.waitingReason).toBe("below_minimum");
expect(small.waitingSince).toBeInstanceOf(Date);
// The later payment is priced (and defers only because Chainlink is down here).
expect((await next.reload()).waitingReason).toBe("oracle_unavailable");
});

it("replaces an earlier hold reason and keeps when the wait started", async () => {
const { accountId, deposit, since } = await accountWithDeposit("oracle_unavailable");
await MoneriumAccount.update({ status: MoneriumAccountStatus.Suspended }, { where: { id: accountId } });
Expand Down
59 changes: 39 additions & 20 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,7 @@ export function canConvert(account: Pick<MoneriumAccount, "dormantSince" | "stat
}

const NOT_ACTIVE: DepositWaitingReason = "account_not_active";
const BELOW_MINIMUM: DepositWaitingReason = "below_minimum";

/**
* Partner-visible hold reason for deposits the activation gate holds (canConvert): set
Expand Down Expand Up @@ -995,7 +996,8 @@ export interface ActionPlanningInput {
* the clone's batch has been open for RECOVERY_DELAY and no other refund of this account is
* in flight (a refund wallet takes one payment at a time); else it waits without blocking
* younger deposits. Then the oldest convertible deposit is forwarded when all of its
* EURe is converted, or swapped in its next chunk.
* EURe is converted, or swapped in its next chunk; one whose remainder is below the minimum
* swap is passed over.
*/
export function planAction(
deposits: Array<{ deposit: MoneriumFiatDeposit; state: DepositSettlementState }>,
Expand All @@ -1020,29 +1022,26 @@ export function planAction(
if (!input.convertible) {
return { kind: "none", reason: "account is not convertible" };
}
const next = deposits.find(({ deposit }) => deposit.status !== MoneriumFiatDepositStatus.Recovering);
if (!next) {
return { kind: "none", reason: "no settling deposit" };
}
if (next.state.remainingEureRaw === 0n) {
if (next.state.usdcNetRaw === 0n) {
return { kind: "none", reason: `deposit ${next.deposit.id} has nothing to forward` };
for (const { deposit, state } of deposits) {
if (deposit.status === MoneriumFiatDepositStatus.Recovering) continue;
if (state.remainingEureRaw === 0n) {
if (state.usdcNetRaw === 0n) {
return { kind: "none", reason: `deposit ${deposit.id} has nothing to forward` };
}
return { deposit, kind: "forward", usdcRaw: state.usdcNetRaw };
}
return { deposit: next.deposit, kind: "forward", usdcRaw: next.state.usdcNetRaw };
}
const amountIn = planChunk(next.state.remainingEureRaw, input.minSwapAmount, input.perSwapCap);
if (amountIn === null) {
const amountIn = planChunk(state.remainingEureRaw, input.minSwapAmount, input.perSwapCap);
// Below the minimum swap: it waits for the refund path and holds back no younger deposit
// (below MIN_SWAP_FLOOR the contract may never let the keeper recover it).
if (amountIn === null) continue;
return {
kind: "none",
reason: `deposit ${next.deposit.id} has ${next.state.remainingEureRaw} raw EURe left, below the minimum swap`
amountIn,
deposit,
elapsedSeconds: chunkElapsedSeconds(deposit, state.lastSwapAt, input.nowMs),
kind: "swap"
};
}
return {
amountIn,
deposit: next.deposit,
elapsedSeconds: chunkElapsedSeconds(next.deposit, next.state.lastSwapAt, input.nowMs),
kind: "swap"
};
return { kind: "none", reason: "no deposit to convert" };
}

// ------------------------------------------------------------------ executor
Expand Down Expand Up @@ -1122,6 +1121,26 @@ export async function runConversionExecutor(accountId: string): Promise<void> {
if (recovered > 0) continue;
withState.push({ deposit, state });
}
// Partner-visible: what planAction passes over below the minimum swap (never shadowing NOT_ACTIVE).
const belowMinimum = withState
.filter(
({ deposit, state }) =>
convertible &&
deposit.status !== MoneriumFiatDepositStatus.Recovering &&
deposit.waitingReason !== BELOW_MINIMUM &&
state.remainingEureRaw > 0n &&
state.remainingEureRaw < minSwapAmount
)
.map(({ deposit }) => deposit.id);
if (belowMinimum.length > 0) {
await MoneriumFiatDeposit.update(
{
waitingReason: BELOW_MINIMUM,
waitingSince: sequelize.fn("COALESCE", sequelize.col("waiting_since"), sequelize.fn("NOW"))
},
{ transaction, where: { id: { [Op.in]: belowMinimum } } }
);
}
return planAction(withState, {
batchOpenedAtSec: batchOpenedAt,
convertible,
Expand Down
179 changes: 177 additions & 2 deletions apps/api/src/api/services/monerium-b2b/monitoring.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
import { describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { Address } from "viem";
import logger from "../../../config/logger";
import { config } from "../../../config/vars";
import ManagedProfileManager from "../../../models/managedProfileManager.model";
import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model";
import MoneriumConversionExecution, {
MoneriumConversionExecutionKind,
MoneriumConversionExecutionStatus
} from "../../../models/moneriumConversionExecution.model";
import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model";
import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db";
import { createTestUser } from "../../../test-utils/factories";
import { provisionMoneriumB2bAccount } from "./account-provisioning";
import * as chain from "./chain";
import {
classifyExecutableDepth,
classifyRefundQueue,
Expand All @@ -8,7 +22,8 @@ import {
detectConfigDrift,
diffAssociation,
eip1167RuntimeCode,
normalizeIban
normalizeIban,
runStrandedBalanceMonitor
} from "./monitoring";

// Pure monitoring logic (implementation plan D3): quote-impact math against the T6
Expand Down Expand Up @@ -246,3 +261,163 @@ describe("eip1167RuntimeCode", () => {
);
});
});

// A refund the keeper can never send: the clone holds less than MIN_SWAP_FLOOR, so the
// contract arms no batch and `recover` reverts. Only the operator can refund it.
describe("runStrandedBalanceMonitor below the swap floor", () => {
const FACTORY = "0x2222222222222222222222222222222222222222" as Address;
const EURE = "0x4444444444444444444444444444444444444444" as Address;
const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl };
let errors: string[];
let warnings: string[];

beforeAll(async () => {
config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check
config.moneriumB2b.forwarderFactoryAddress = FACTORY;
await setupTestDatabase();
});

afterAll(() => {
config.moneriumB2b.rpcUrl = saved.rpcUrl;
config.moneriumB2b.forwarderFactoryAddress = saved.factory;
});

beforeEach(async () => {
await resetTestDatabase();
errors = [];
warnings = [];
const reads: Record<string, unknown> = { batchOpenedAt: 0n, MIN_SWAP_FLOOR: 1n * EUR, TRIGGER_DELAY: 86_400n };
spyOn(chain, "getForwarderImmutables").mockResolvedValue({
eure: EURE,
factory: FACTORY,
recoveryDelaySeconds: 7_200,
usdc: "0x6666666666666666666666666666666666666666"
} as unknown as chain.ForwarderImmutables);
spyOn(chain, "getPublicClient").mockReturnValue({
readContract: async ({ address, functionName }: { address: Address; functionName: string }) =>
functionName === "balanceOf" ? (address === EURE ? EUR / 2n : 0n) : reads[functionName]
} as unknown as ReturnType<typeof chain.getPublicClient>);
spyOn(logger, "error").mockImplementation(((message: string) => {
errors.push(message);
}) as unknown as typeof logger.error);
spyOn(logger, "warn").mockImplementation(((message: string) => {
warnings.push(message);
}) as unknown as typeof logger.warn);
});

afterEach(() => mock.restore());

async function accountWithDeposit(status: MoneriumFiatDepositStatus, amountRaw = EUR / 2n) {
const manager = await createTestUser();
await ManagedProfileManager.create({
allowedCorridors: ["EU"],
allowedCustomerTypes: ["business"],
isActive: true,
profileId: manager.id
});
const { accountId } = await provisionMoneriumB2bAccount({
contactEmail: "ops@client.example.com",
destination: "0x5555555555555555555555555555555555555555",
externalSubjectId: "client-1",
forwarderAddress: "0x1111111111111111111111111111111111111111",
managerProfileId: manager.id,
moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"
});
await MoneriumAccount.update({ status: MoneriumAccountStatus.Active }, { where: { id: accountId } });
return addDeposit(accountId, status, amountRaw);
}

let orders = 0;
function addDeposit(accountId: string, status: MoneriumFiatDepositStatus, amountRaw: bigint) {
orders += 1;
return MoneriumFiatDeposit.create({
accountId,
amountRaw: amountRaw.toString(),
blockNumber: 100,
chainId: 11155111,
currency: "eur",
logIndex: orders,
mintedAt: new Date(),
moneriumOrderId: `order-${orders}`,
payerIban: "DE89370400440532013000",
payerName: "Payer GmbH",
status,
txHash: `0xorder${orders}`
});
}

function execution(deposit: MoneriumFiatDeposit, kind: MoneriumConversionExecutionKind, status: MoneriumConversionExecutionStatus) {
return MoneriumConversionExecution.create({
accountId: deposit.accountId,
depositId: deposit.id,
destination: "0x5555555555555555555555555555555555555555",
eureInRaw: deposit.amountRaw,
kind,
status,
usdcNetRaw: "0"
});
}

it("asks the operator to refund a payment marked for recovery that the contract cannot recover", async () => {
const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering);
await runStrandedBalanceMonitor();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain(deposit.id);
expect(errors[0]).toContain("refund each by hand");
expect(errors[0]).toContain("vortex-refund:<depositId>");
});

for (const status of [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) {
it(`stays quiet while the keeper's recover is ${status} (the automatic refund is under way)`, async () => {
const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering);
await execution(deposit, MoneriumConversionExecutionKind.Recover, status);
await runStrandedBalanceMonitor();
expect(errors).toEqual([]);
});
}

it("never asks to refund a payment whose funds already left the clone", async () => {
// A 100 EUR payment converted and forwarded on the permissionless path, unseen by the ledger.
const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, 100n * EUR);
await runStrandedBalanceMonitor();
expect(errors).toEqual([]);
expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true);
});

it("never asks to refund a swapped payment, even when the clone holds as much EURe", async () => {
// Its USDC left on the unrecorded forwardAll; the 0.5 EURe on the clone is someone else's.
const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 4n) / 10n);
await execution(deposit, MoneriumConversionExecutionKind.Swap, MoneriumConversionExecutionStatus.Confirmed);
await runStrandedBalanceMonitor();
expect(errors).toEqual([]);
expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true);
});

// EURe the ledger places on the clone for other payments: a younger one still settling, or
// one refunded by hand (its EURe never left the clone).
for (const other of [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Refunded]) {
it(`does not count a ${other} payment's EURe as the marked payment's`, async () => {
const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 3n) / 10n);
await addDeposit(deposit.accountId, other, (EUR * 4n) / 10n);
await runStrandedBalanceMonitor();
expect(errors).toEqual([]);
expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true);
});
}

it("still asks to refund when the clone holds the marked payment and the others' EURe", async () => {
const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 2n) / 10n);
await addDeposit(deposit.accountId, MoneriumFiatDepositStatus.Minted, (EUR * 3n) / 10n);
const recovered = await addDeposit(deposit.accountId, MoneriumFiatDepositStatus.Refunded, EUR);
await execution(recovered, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Confirmed);
await runStrandedBalanceMonitor();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain(deposit.id);
});

it("stays quiet while such a payment is not marked for recovery yet", async () => {
await accountWithDeposit(MoneriumFiatDepositStatus.Minted);
await runStrandedBalanceMonitor();
expect(errors).toEqual([]);
});
});
Loading
Loading