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
219 changes: 219 additions & 0 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,225 @@ describe("runConversionExecutor activation gate", () => {
});
});

// The batch marker starts the RECOVERY_DELAY clock, so a cycle that sends no swap must
// still arm it: otherwise a deposit whose every attempt fails waits a whole extra delay
// for its refund.
describe("runConversionExecutor batch marker", () => {
const FACTORY = "0x2222222222222222222222222222222222222222" as Address;
const EURE = "0x4444444444444444444444444444444444444444" as Address;
const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl };

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(() => resetTestDatabase());
afterEach(() => mock.restore());

/** A funded clone with a ready-priced 100 EURe swap; returns every keeper write as `fn:nonce`. */
function arrange(options: { batchOpenedAt: bigint; failPokeWrite?: boolean; swapReverts?: boolean }) {
const writes: string[] = [];
const reads: Record<string, unknown> = {
batchOpenedAt: options.batchOpenedAt,
floorPpm: 1_500,
latestRoundData: [1n, 114_000_000n, 0n, 0n, 1n],
MIN_SWAP_FLOOR: 1n,
minSwapAmount: 25n * EUR,
perSwapCap: 10_000n * EUR,
subsidyVault: "0x0000000000000000000000000000000000000000",
targetPpm: 1_250
};
spyOn(chain, "getForwarderImmutables").mockResolvedValue({
eure: EURE,
factory: FACTORY,
maxReferenceDeviationBps: 100,
oracle: "0x5555555555555555555555555555555555555555",
oracleDecimals: 8,
recoveryDelaySeconds: 7_200,
usdc: "0x6666666666666666666666666666666666666666"
} as unknown as chain.ForwarderImmutables);
spyOn(chain, "getChainId").mockResolvedValue(11_155_111);
spyOn(chain, "readEnabledRoutes").mockResolvedValue([{ index: 0, path: "0xaa" as Hex }]);
spyOn(referenceRate, "fetchCoinbaseReference").mockResolvedValue({
price: "1.14000000",
rateRaw: 114_000_000n,
source: "test",
time: new Date(0)
});
spyOn(chain, "getPublicClient").mockReturnValue({
getBlockNumber: async () => 100n,
getTransactionCount: async () => 7,
readContract: async ({ address, functionName }: { address: Address; functionName: string }) =>
functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName],
simulateContract: async ({ functionName }: { functionName: string }) => {
if (functionName === "swap" && options.swapReverts) throw new Error("execution reverted");
return {};
},
waitForTransactionReceipt: async () => {
throw new Error("receipt timeout");
}
} as unknown as ReturnType<typeof chain.getPublicClient>);
spyOn(chain, "getKeeperWalletClient").mockReturnValue({
account: { address: "0x9999999999999999999999999999999999999999" },
writeContract: async ({ functionName, nonce }: { functionName: string; nonce?: number }) => {
writes.push(`${functionName}:${nonce ?? "auto"}`);
if (functionName === "poke" && options.failPokeWrite) throw new Error("poke rejected");
return `0x${writes.length.toString(16).padStart(64, "0")}`;
}
} as unknown as ReturnType<typeof chain.getKeeperWalletClient>);
return writes;
}

async function activeAccountWithDeposit() {
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({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } });
const deposit = await MoneriumFiatDeposit.create({
accountId,
amountRaw: (100n * EUR).toString(),
blockNumber: 100,
chainId: 11155111,
currency: "eur",
logIndex: 1,
mintedAt: new Date(),
moneriumOrderId: "order-1",
payerIban: "DE89370400440532013000",
payerName: "Payer GmbH",
status: MoneriumFiatDepositStatus.Minted,
txHash: "0xorder1"
});
return { accountId, deposit };
}

const executions = (accountId: string) => MoneriumConversionExecution.findAll({ where: { accountId } });

async function failedAttempt(accountId: string, depositId: string) {
await MoneriumConversionExecution.create({
accountId,
depositId,
destination: "0x5555555555555555555555555555555555555555",
eureInRaw: (100n * EUR).toString(),
error: "attempt 1: execution reverted",
kind: MoneriumConversionExecutionKind.Swap,
status: MoneriumConversionExecutionStatus.Failed
});
}

it("pokes once when the swap attempt fails before sending", async () => {
const writes = arrange({ batchOpenedAt: 0n, swapReverts: true });
const { accountId } = await activeAccountWithDeposit();

await runConversionExecutor(accountId);

expect(writes).toEqual(["poke:auto"]);
expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]);
});

it("pokes during the retry backoff without starting an attempt", async () => {
const writes = arrange({ batchOpenedAt: 0n });
const { accountId, deposit } = await activeAccountWithDeposit();
await failedAttempt(accountId, deposit.id);

await runConversionExecutor(accountId);

expect(writes).toEqual(["poke:auto"]);
expect(await executions(accountId)).toHaveLength(1);
});

it("never pokes an armed batch, on a failed attempt or during backoff", async () => {
const writes = arrange({ batchOpenedAt: 1n, swapReverts: true });
const { accountId, deposit } = await activeAccountWithDeposit();

await runConversionExecutor(accountId); // fails the attempt
await failedAttempt(accountId, deposit.id);
await runConversionExecutor(accountId); // backoff

expect(writes).toEqual([]);
expect(await executions(accountId)).toHaveLength(2); // no third attempt: the backoff held
});

it("pokes when a crashed pre-send reservation expires into a backoff", async () => {
const writes = arrange({ batchOpenedAt: 0n });
const { accountId, deposit } = await activeAccountWithDeposit();
await MoneriumConversionExecution.create({
accountId,
createdAt: new Date(Date.now() - 10 * 60_000),
depositId: deposit.id,
destination: "0x5555555555555555555555555555555555555555",
eureInRaw: (100n * EUR).toString(),
kind: MoneriumConversionExecutionKind.Swap,
status: MoneriumConversionExecutionStatus.Pending
});

await runConversionExecutor(accountId);

expect(writes).toEqual(["poke:auto"]);
expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]);
});

it("leaves the poke to another executor whose execution appears mid-cycle", async () => {
const writes = arrange({ batchOpenedAt: 0n });
const { accountId, deposit } = await activeAccountWithDeposit();
// Another process reserves its execution after this cycle's first pending check.
spyOn(referenceRate, "fetchCoinbaseReference").mockImplementation(async () => {
await MoneriumConversionExecution.create({
accountId,
depositId: deposit.id,
destination: "0x5555555555555555555555555555555555555555",
eureInRaw: (100n * EUR).toString(),
kind: MoneriumConversionExecutionKind.Swap,
status: MoneriumConversionExecutionStatus.Pending
});
return { price: "1.14000000", rateRaw: 114_000_000n, source: "test", time: new Date(0) };
});

await runConversionExecutor(accountId);

expect(writes).toEqual([]);
expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Pending]);
});

it("does not poke again after the sequenced poke was attempted", async () => {
const writes = arrange({ batchOpenedAt: 0n, failPokeWrite: true });
const { accountId } = await activeAccountWithDeposit();

await runConversionExecutor(accountId);

expect(writes).toEqual(["poke:7"]);
expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]);
});

it("keeps the poke-then-swap sequence on a sent attempt, with no extra poke", async () => {
const writes = arrange({ batchOpenedAt: 0n });
const { accountId } = await activeAccountWithDeposit();

await runConversionExecutor(accountId); // the receipt wait times out: the row stays pending

expect(writes).toEqual(["poke:7", "swap:8"]);
expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Pending]);
});
});

// A reservation whose broadcast never happened (the process died between `reserve` and
// `send`) holds a nonce nobody will use; the keeper re-sends the identical call there.
describe("runConversionExecutor reserved-nonce re-send", () => {
Expand Down
26 changes: 21 additions & 5 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,12 @@ async function settleDeposit(

// ------------------------------------------------------------------ pending resolution + backoff

type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; reason: string };
// "backoff" (no pending execution left, waiting out failures) is the one wait whose poke this
// executor owes; a "skip" waits on a pending execution, whose own sequence carries the poke.
type PreparationResult =
| { kind: "proceed"; attempt: number }
| { kind: "skip"; reason: string }
| { kind: "backoff"; reason: string };

export type HashlessPendingClassification =
| { kind: "fail"; reason: string }
Expand Down Expand Up @@ -811,7 +816,7 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans
const backoffMs = Math.min(RETRY_BASE_MS * 2 ** (failedSince.length - 1), RETRY_MAX_MS);
const nextAttemptAt = failedSince[0].updatedAt.getTime() + backoffMs;
if (Date.now() < nextAttemptAt) {
return { kind: "skip", reason: `retry backoff until ${new Date(nextAttemptAt).toISOString()}` };
return { kind: "backoff", reason: `retry backoff until ${new Date(nextAttemptAt).toISOString()}` };
}
}
return { attempt: failedSince.length + 1, kind: "proceed" };
Expand Down Expand Up @@ -1066,6 +1071,7 @@ export async function runConversionExecutor(accountId: string): Promise<void> {
const recovery = await withForwarderLock(account.forwarderAddress, transaction =>
prepareExecutionSlot(account, transaction)
);
// A backoff falls through: the slot check below pokes for it.
if (recovery.kind === "skip") {
logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${recovery.reason}`);
return;
Expand Down Expand Up @@ -1173,7 +1179,7 @@ export async function runConversionExecutor(accountId: string): Promise<void> {
// transactions, two concurrent executors could both pass the check and both broadcast.
const slot = await withForwarderLock(account.forwarderAddress, async transaction => {
const preparation = await prepareExecutionSlot(account, transaction);
if (preparation.kind === "skip") {
if (preparation.kind !== "proceed") {
return preparation;
}
// Execution-before-send record: committed before any broadcast so a crash leaves an
Expand Down Expand Up @@ -1203,11 +1209,16 @@ export async function runConversionExecutor(accountId: string): Promise<void> {
}
return { attempt: preparation.attempt, execution, kind: "proceed" as const };
});
if (slot.kind === "skip") {
if (slot.kind !== "proceed") {
logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${slot.reason}`);
if (slot.kind === "backoff" && pokeNeeded) {
await sendPoke(forwarder);
}
return;
}
const { attempt, execution } = slot;
// Cleared once the sequenced poke is attempted, so a failed attempt never pokes twice.
let pokeOutstanding = pokeNeeded;

try {
const keeper = getKeeperWalletClient();
Expand Down Expand Up @@ -1246,6 +1257,7 @@ export async function runConversionExecutor(accountId: string): Promise<void> {
},
send: nonce => writeCall(keeper, forwarder, call.request, nonce),
sendPoke: async nonce => {
pokeOutstanding = false;
await keeper.writeContract({
abi: forwarderAbi,
account: keeper.account,
Expand Down Expand Up @@ -1284,6 +1296,10 @@ export async function runConversionExecutor(accountId: string): Promise<void> {
status: MoneriumConversionExecutionStatus.Failed
});
logger.error(`monerium-b2b: ${call.kind} for account ${account.id} failed (attempt ${attempt}):`, error);
// Nothing was sent: still arm the marker, or the recovery clock waits for the next cycle.
if (pokeOutstanding) {
await sendPoke(forwarder);
}
}
}

Expand Down Expand Up @@ -1365,7 +1381,7 @@ function executionCall(
}
}

/** Standalone batch-marker poke for funds the keeper cannot act on yet. */
/** Standalone batch-marker poke for funds the keeper does not act on this cycle. */
async function sendPoke(forwarder: Address): Promise<void> {
try {
const client = getPublicClient();
Expand Down
6 changes: 5 additions & 1 deletion docs/architecture-monerium-b2b-onramp.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,11 @@ and sends at most one transaction per account per cycle:
endpoint, or once automated by the missed window — is moved off the clone with
`recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the
clone's fixed `recoveryAddress` (the client's refund wallet), and only once the clone's `batchOpenedAt` marker is older
than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive, is never
than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive (the keeper
sends the permissionless `poke()` ahead of the first swap, and on its own in a cycle
that sends no swap: nothing to do, a deferral, a retry backoff or a failed attempt, so
a deposit whose every attempt fails is still refundable on time; a cycle waiting on a
pending execution leaves the poke to that execution's own sequence), is never
re-timed by a chunk swap, and is re-timed for whatever remains after a forward or a
recovery, so a younger payment sharing the clone gets its own clock. The keeper
recovers before it converts anything else, and still does so on suspended or dormant
Expand Down
2 changes: 1 addition & 1 deletion docs/security-spec/05-integrations/monerium-b2b.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim
- [ ] The reference midpoint rejects a malformed ticker, an inverted or empty book and a spread above 50 bps (`reference-rate.test.ts`)
- [ ] Keeper, guardian, and attestor private keys are three distinct keys in production, the deployer key differs from those and the float key (`vars.test.ts`); none logged
- [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs)
- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts`
- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle (a cycle waiting on a pending execution leaves it to that execution's sequence); no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts`
- [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing
- [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`)
- [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch
Expand Down
Loading