Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
ce284de
fix(api): quote and project b2b swaps on the sepolia quoterv2
ebma Oct 9, 2026
91d7964
docs(api): describe the per-chain b2b quoter pin
ebma Oct 9, 2026
3437807
docs(api): note the depth alert now also fires on the sandbox
ebma Oct 9, 2026
68724d1
fix(api): poke the batch marker when a monerium cycle sends no swap
ebma Oct 9, 2026
ee8b2cd
docs(api): note the marker poke on cycles that send no swap
ebma Oct 9, 2026
f050f2e
test(api): assert the armed-batch marker test takes the backoff
ebma Oct 9, 2026
466abbf
fix(api): poke only on the backoff when a monerium cycle skips
ebma Oct 9, 2026
72e8954
docs(api): note that a pending execution carries its own poke
ebma Oct 9, 2026
3ebc796
fix(api): apply due fee-policy increases in the keeper cycle
ebma Oct 9, 2026
bcc8897
docs(api): document the keeper's fee-policy apply step
ebma Oct 9, 2026
62d293f
fix(api): skip the fee-policy apply while a keeper send is unmined
ebma Oct 9, 2026
81517e2
test(api): stub the fee-policy read in the recovery executor fixture
ebma Oct 9, 2026
67e2e19
docs(api): note the unmined guard and idle-clone timing of fee applies
ebma Oct 9, 2026
c5a8736
feat(shared): add a below_minimum deposit waiting reason
ebma Oct 9, 2026
fb677bf
fix(api): keep sub-minimum deposits from stalling the keeper
ebma Oct 9, 2026
5101ff3
fix(api): ask for a manual refund only while funds are on the clone
ebma Oct 9, 2026
cd104cf
docs(api): describe the manual refund alert and marker re-timing
ebma Oct 9, 2026
3e290f2
fix(api): weigh the whole ledger before a manual refund alert
ebma Oct 9, 2026
9af2c0f
test(api): cover the swapped-deposit guard of the manual refund alert
ebma Oct 9, 2026
e7b00e7
docs(api): count poke and fee applies apart from the cycle's send
ebma Oct 9, 2026
6e68fdf
fix(api): clear below_minimum once a deposit meets a lowered minimum
ebma Oct 9, 2026
d85a0ac
fix(api): prescribe an eure top-up for refunds below the swap floor
ebma Oct 10, 2026
3bf2881
fix(api): skip the sub-floor refund check while a recover is pending
ebma Oct 10, 2026
2cf793a
fix(api): alert on marked deposits the keeper never recovers
ebma Oct 10, 2026
81c7c80
fix(api): log the sandbox depth alert at warn level
ebma Oct 10, 2026
00d03e6
docs(api): record how payments below the swap floor are refunded
ebma Oct 10, 2026
6930971
fix(api): start a fresh wait for a deposit cleared from below_minimum
ebma Oct 10, 2026
c13fc2a
fix(api): back a planned recover off only on failed recovers
ebma Oct 10, 2026
950f2f0
fix(api): read the pending fee policy inside the best-effort apply
ebma Oct 10, 2026
94eb7aa
fix(api): remember the last fee apply so a private one is not re-sent
ebma Oct 10, 2026
e0a2254
test(api): pin the next-cycle poke after a failed sequenced poke
ebma Oct 10, 2026
4967878
docs(api): count poke and fee apply in the keeper module comment
ebma Oct 10, 2026
dfe96ea
docs(api): bound the marker delay after a dropped sequenced poke
ebma Oct 10, 2026
13316dd
fix(api): report refundedAt for a refund closed without a recovery row
ebma Oct 10, 2026
35e7c62
docs(api): say deposit waiting reasons may grow
ebma Oct 10, 2026
666326c
docs(api): state the ordering cost of passing over small payments
ebma Oct 10, 2026
b3da667
docs(api): add audit checklist items for the keeper gap fixes
ebma Oct 10, 2026
5e1382c
docs(api): match the overdue alert's scope to the monitor
ebma Oct 10, 2026
216292a
fix(api): keep every refund top-up off a whole cent
ebma Oct 10, 2026
52056c4
fix(api): point the top-up alert to the manual redeem outside auto mode
ebma Oct 10, 2026
c16aa15
fix(api): date a failed refund closed by hand at its close
ebma Oct 10, 2026
5f47679
fix(api): keep refund top-ups from making an account a keeper candidate
ebma Oct 10, 2026
b56c047
docs(api): name every process that can resend a remembered fee apply
ebma Oct 10, 2026
2009a90
fix(api): date every refund by the deposit's refunded transition
ebma Oct 10, 2026
46dff7f
fix(api): alert on a recover the keeper leaves pending
ebma Oct 10, 2026
6af3e22
docs(api): match the top-up checklist item to the alert
ebma Oct 10, 2026
bfd61a3
style(api): use a plain string in the stuck recover alert
ebma Oct 10, 2026
9fce85a
fix(api): keep a late payer fill from re-dating a refunded deposit
ebma Oct 10, 2026
6d26f99
docs(api): drop the stale refundedAt fallback wording
ebma Oct 10, 2026
ed84737
fix(api): keep accounts with a pending execution keeper candidates
ebma Oct 10, 2026
1c6346d
docs(api): scope the no-manual-refund rule to auto recovery
ebma Oct 10, 2026
787df9b
docs(api): state the single poke an orphan inflow gets
ebma Oct 10, 2026
222aa70
docs(api): say why an orphan inflow's poke is not retried
ebma Oct 10, 2026
5edb60b
fix(api): raise the stuck recover alert before the clone reads
ebma Oct 10, 2026
aa8f29e
docs(api): describe refundedAt as the recorded refund, by hand too
ebma Oct 10, 2026
462642e
refactor(api): clear below_minimum through the loaded deposits
ebma Oct 12, 2026
c1125f7
refactor(api): drop the monitor's unreachable pending-recover filter
ebma Oct 12, 2026
0b84e7d
docs(api): shorten the fee-apply docstring
ebma Oct 12, 2026
5b434ef
test(api): drop a fee-apply test two sharper tests cover
ebma Oct 12, 2026
d819b97
test(api): share the account helper of the batch-marker and fee tests
ebma Oct 12, 2026
1ed1131
test(api): fold the quoter checks into the depth-check cases
ebma Oct 12, 2026
ff20387
docs(api): state the unattr: candidate rule once
ebma Oct 12, 2026
b3ec41b
docs(api): trim repetition in the sub-floor refund docs
ebma Oct 12, 2026
fe900e2
docs(api): point the fee-apply resend bound to invariant 2
ebma Oct 12, 2026
9bfc834
fix(api): read the stranded monitor's floor and delays per clone
ebma Oct 19, 2026
f4eb525
fix(api): hold the sub-floor top-up while a sibling needs reconciling
ebma Oct 19, 2026
48731b7
fix(api): hold the sub-floor top-up while the client's refund is open
ebma Oct 19, 2026
90f3fcf
fix(api): name failing recovers in the overdue refund alert
ebma Oct 19, 2026
da632df
fix(api): report sub-floor payments past their window in off mode
ebma Oct 19, 2026
8440a64
fix(api): cite the internal B2B runbook in the depth alert
ebma Oct 19, 2026
ad823f8
docs(api): scope the depth alert's error level to mainnet in the spec
ebma Oct 19, 2026
b4b1977
test(api): give each fee-apply test its own forwarder
ebma Oct 19, 2026
853bf71
test(api): pin the keeper side of the sub-floor top-up refund
ebma Oct 19, 2026
35440c7
docs(api): list every kind of keeper candidate in the keeper docs
ebma Oct 19, 2026
70d00a9
docs(api): close the spec's stranded monitor with the off-mode report
ebma Oct 19, 2026
52e82b2
fix(api): point the sub-floor wait lines to REFUND NEEDS OPERATOR
ebma Oct 19, 2026
4c397cf
fix(api): report sub-floor payments past their window beside marked ones
ebma Oct 19, 2026
41a6afd
fix(api): name the open refund in the sub-floor top-up wait line
ebma Oct 19, 2026
9384161
test(api): pin the off-mode report's chain-indexed filter
ebma Oct 19, 2026
bfb400a
docs(api): cite the internal B2B runbook in the monitor docs
ebma Oct 19, 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
31 changes: 25 additions & 6 deletions apps/api/src/api/services/monerium-b2b/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const forwarderAbi = [
type: "function"
},
{ inputs: [], name: "forwardAll", outputs: [], stateMutability: "nonpayable", type: "function" },
{ inputs: [], name: "applyFeePolicy", outputs: [], stateMutability: "nonpayable", type: "function" },
{
inputs: [
{ name: "eureAmount", type: "uint256" },
Expand Down Expand Up @@ -166,7 +167,14 @@ export const forwarderAbi = [
type: "function"
},
{ inputs: [], name: "targetPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" },
{ inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }
{ inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" },
{
inputs: [],
name: "pendingFeePolicyEffectiveAt",
outputs: [{ name: "", type: "uint64" }],
stateMutability: "view",
type: "function"
}
] as const;

export const factoryAbi = [
Expand Down Expand Up @@ -200,8 +208,19 @@ export const chainlinkAbi = parseAbi([
"function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)"
]);

/** Uniswap V3 QuoterV2 on Ethereum mainnet (the pinned quoting contract, PRD §7.4). */
export const MAINNET_QUOTER_V2: Address = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e";
/**
* Pinned Uniswap V3 QuoterV2 per chain id (PRD §7.4): Ethereum mainnet and the Sepolia
* sandbox, each on the same Uniswap v3 deployment as the chain's pools and router.
*/
const QUOTER_V2_BY_CHAIN_ID: Record<number, Address> = {
1: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
11155111: "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3"
};

/** The pinned QuoterV2 for `chainId`; null on a chain without one (no quoting there). */
export function quoterV2ForChainId(chainId: number): Address | null {
return QUOTER_V2_BY_CHAIN_ID[chainId] ?? null;
}

export const quoterV2Abi = parseAbi([
"function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)"
Expand Down Expand Up @@ -419,11 +438,11 @@ export async function readEnabledRoutes(factory: Address): Promise<Array<{ index
return routes.filter(route => route.enabled).map(({ index, path }) => ({ index, path }));
}

/** Static QuoterV2 quote for `amountIn` over a packed path. Mainnet only (MAINNET_QUOTER_V2 pin). */
export async function quoteRouteOutput(path: Hex, amountIn: bigint): Promise<bigint> {
/** Static quote for `amountIn` over a packed path on `quoter` (from quoterV2ForChainId). */
export async function quoteRouteOutput(quoter: Address, path: Hex, amountIn: bigint): Promise<bigint> {
const { result } = await getPublicClient().simulateContract({
abi: quoterV2Abi,
address: MAINNET_QUOTER_V2,
address: quoter,
args: [path, amountIn],
functionName: "quoteExactInput"
});
Expand Down
624 changes: 613 additions & 11 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts

Large diffs are not rendered by default.

219 changes: 182 additions & 37 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.ts

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,35 @@ describe("order-event inbox processing (end to end)", () => {
expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0);
});

it("leaves a refunded deposit untouched when a later delivery carries the payer", async () => {
const account = await createAccount();
const deposit = await MoneriumFiatDeposit.create({
accountId: account.id,
amountRaw: (1005n * 10n ** 17n).toString(),
currency: "eur",
moneriumOrderId: ORDER_ID,
status: Refunded,
txHash: "0xmint"
});
const refundedAt = deposit.updatedAt;
await MoneriumWebhookEvent.create({
eventId: "evt-late-payer",
payload: orderEvent("processed", {
counterpart: {
details: { name: "Payer GmbH" },
identifier: { iban: "DE89370400440532013000", standard: "iban" }
}
})
});

expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1);
await deposit.reload();
// updated_at dates refundedAt, so a late fill would re-date the refund.
expect(deposit.updatedAt).toEqual(refundedAt);
expect(deposit.payerIban).toBeNull();
expect(deposit.payerName).toBeNull();
});

it("terminally discards malformed amounts without delaying later orders", async () => {
await createAccount();
await MoneriumWebhookEvent.create({
Expand Down
8 changes: 5 additions & 3 deletions apps/api/src/api/services/monerium-b2b/deposit-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,9 +384,11 @@ async function processInboxRow(row: MoneriumWebhookEvent, deps: DepositProcessor
status?: MoneriumFiatDepositStatus;
txHash?: string;
} = {};
// The refund target: filled once, never overwritten by a later delivery.
if (event.payerIban && !existing.payerIban) updates.payerIban = event.payerIban;
if (event.payerName && !existing.payerName) updates.payerName = event.payerName;
// The refund target: filled once, never overwritten by a later delivery, and never
// after the refund (the write would move updated_at, which dates refundedAt).
const fillPayer = existing.status !== MoneriumFiatDepositStatus.Refunded;
if (fillPayer && event.payerIban && !existing.payerIban) updates.payerIban = event.payerIban;
if (fillPayer && event.payerName && !existing.payerName) updates.payerName = event.payerName;
const alreadyPastMint = targetStatus === MoneriumFiatDepositStatus.Minted && PAST_MINT_STATUSES.includes(existing.status);
if (targetStatus && targetStatus !== existing.status && !alreadyPastMint) {
if (isForwardTransition(existing.status, targetStatus)) {
Expand Down
84 changes: 84 additions & 0 deletions apps/api/src/api/services/monerium-b2b/manager-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { provisionMoneriumB2bAccount } from "./account-provisioning";
import { NOTIFY_CONFIRMATION_DEPTH } from "./chain";
import { markDepositForRecovery } from "./conversion-executor";
import { emitMoneriumDepositEvents, maskIban } from "./manager-events";
import { runRecoveryOrchestrator, setDepositStatus } from "./recovery";

const FORWARDER = "0x1111111111111111111111111111111111111111";
const DESTINATION = "0x2222222222222222222222222222222222222222";
Expand Down Expand Up @@ -335,6 +336,89 @@ describe("monerium b2b manager events", () => {
expect(maskIban("EE08 7224 5745 6244 9516")).toBe("EE08…9516");
});

it("reports a refund closed by hand as refunded at its refunded transition", async () => {
const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RETURNED, WebhookEventType.DEPOSIT_UPDATED]);
const deposit = await MoneriumFiatDeposit.create({
accountId: mapped.accountId,
amountRaw: "500000000000000000",
blockNumber: 999,
chainId: 11155111,
currency: "eur",
logIndex: 1,
moneriumOrderId: "order-1",
payerIban: "DE89370400440532013000",
receivedEventAt: new Date(),
refundReason: "window_missed",
refundStartedAt: new Date(),
status: MoneriumFiatDepositStatus.Recovering,
txHash: "0xmint"
});
// The admin PATCH path: no recover execution, no MoneriumRecovery row.
expect(await setDepositStatus(deposit, MoneriumFiatDepositStatus.Refunded)).toBeNull();
await deposit.reload();
const refundedAt = deposit.updatedAt.toISOString();
await new Promise(resolve => setTimeout(resolve, 5));

await emitMoneriumDepositEvents(depsAtBlock(null));
await emitMoneriumDepositEvents(depsAtBlock(null));
const updates = (await WebhookDelivery.findAll())
.map(delivery => delivery.payload as unknown as { eventType: string; payload: Record<string, unknown> })
.filter(payload => payload.eventType === WebhookEventType.DEPOSIT_UPDATED);
expect(updates).toHaveLength(1);
expect(updates[0].payload).toMatchObject({
refund: { recoverTxHash: null, redeemOrderId: null, refundedAt },
status: "refunded"
});
});

it("dates a failed refund closed by hand at its refunded transition, not at the failure or the row's close", async () => {
const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RETURNED, WebhookEventType.DEPOSIT_UPDATED]);
const deposit = await MoneriumFiatDeposit.create({
accountId: mapped.accountId,
amountRaw: "500000000000000000",
blockNumber: 999,
chainId: 11155111,
currency: "eur",
logIndex: 1,
moneriumOrderId: "order-1",
payerIban: "DE89370400440532013000",
receivedEventAt: new Date(),
refundReason: "window_missed",
refundStartedAt: new Date(),
status: MoneriumFiatDepositStatus.RecoveryFailed,
txHash: "0xmint"
});
// The orchestrator parked the refund at topping_up: the row's last write is the failure.
const recovery = await MoneriumRecovery.create({
depositId: deposit.id,
error: "float top-up reverted",
eureRecoveredRaw: deposit.amountRaw,
phase: MoneriumRecoveryPhase.ToppingUp,
usdcRecoveredRaw: "0"
});
await new Promise(resolve => setTimeout(resolve, 5));
// The operator refunds by hand and closes it through the admin PATCH (recovery_failed -> refunded).
expect(await setDepositStatus(deposit, MoneriumFiatDepositStatus.Refunded)).toBeNull();
await deposit.reload();
expect(deposit.updatedAt.getTime()).toBeGreaterThan(recovery.updatedAt.getTime());
await new Promise(resolve => setTimeout(resolve, 5));
// The next keeper cycle closes the parked row before the events run.
await runRecoveryOrchestrator(() => Promise.reject(new Error("a refunded head never needs deps")));
await recovery.reload();
expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeemed);
expect(recovery.updatedAt.getTime()).toBeGreaterThan(deposit.updatedAt.getTime());

await emitMoneriumDepositEvents(depsAtBlock(null));
const updates = (await WebhookDelivery.findAll())
.map(delivery => delivery.payload as unknown as { eventType: string; payload: Record<string, unknown> })
.filter(payload => payload.eventType === WebhookEventType.DEPOSIT_UPDATED);
expect(updates).toHaveLength(1);
expect(updates[0].payload).toMatchObject({
refund: { refundedAt: deposit.updatedAt.toISOString() },
status: "refunded"
});
});

it("only enqueues to the controlling manager's webhooks", async () => {
const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]);
const otherManager = await createTestUser();
Expand Down
11 changes: 8 additions & 3 deletions apps/api/src/api/services/monerium-b2b/manager-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ export async function depositSnapshots(
reason: deposit.refundReason as DepositRefundReason | null,
recoverTxHash: recover?.txHash ?? null,
redeemOrderId: recovery?.redeemOrderId ?? null,
refundedAt: deposit.status === MoneriumFiatDepositStatus.Refunded ? iso(recovery?.updatedAt) : null,
// Dated by the deposit's refunded transition, its last write (the received/returned markers
// are silent, the status is terminal): the orchestrator closes the recovery row after it, or
// a cycle later for a refund closed by hand, so that row's time would drift between snapshots.
refundedAt: deposit.status === MoneriumFiatDepositStatus.Refunded ? iso(deposit.updatedAt) : null,
startedAt: iso(deposit.refundStartedAt)
}
: null,
Expand Down Expand Up @@ -240,7 +243,8 @@ async function emitReceivedEvents(): Promise<void> {
// Marked emitted even with zero subscribers: webhooks are forward-looking, a
// later registration must not receive the whole history. A crash between the
// enqueue and this marker is absorbed by the outbox (webhook_id, event_id) dedup.
await deposit.update({ receivedEventAt: new Date() });
// Silent: updated_at dates a refunded deposit's refundedAt.
await deposit.update({ receivedEventAt: new Date() }, { silent: true });
} catch (error) {
// Per-deposit isolation: one failing deposit must not block its siblings.
logger.error(`monerium-b2b: DEPOSIT_RECEIVED emission failed for deposit ${deposit.id}:`, error);
Expand Down Expand Up @@ -351,7 +355,8 @@ async function emitReturnedEvents(): Promise<void> {
timestamp: new Date().toISOString()
};
await enqueueForManager(WebhookEventType.DEPOSIT_RETURNED, managerProfileId, payload);
await deposit.update({ returnedEventAt: new Date() });
// Silent: updated_at stays the refunded transition, which dates refundedAt.
await deposit.update({ returnedEventAt: new Date() }, { silent: true });
} catch (error) {
logger.error(`monerium-b2b: DEPOSIT_RETURNED emission failed for deposit ${deposit.id}:`, error);
}
Expand Down
Loading
Loading