diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 265d84436..89a7a7f93 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -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", () => { @@ -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 () => { @@ -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 } }); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index 087b1617f..97bf8255c 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -952,6 +952,7 @@ export function canConvert(account: Pick, @@ -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 @@ -1122,6 +1121,26 @@ export async function runConversionExecutor(accountId: string): Promise { 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, diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts index 9a472292f..bfa8d307d 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -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, @@ -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 @@ -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 = { 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); + 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:"); + }); + + 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([]); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 99ee289a1..9e201163e 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -3,6 +3,11 @@ import { Address, formatUnits, Hex, parseAbi } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; import { chainlinkAbi, @@ -19,6 +24,7 @@ import { readSubsidyVaultState, SubsidyVaultState } from "./chain"; +import { settlementState } from "./conversion-executor"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; import { refundAccountFor } from "./refund-wallet"; @@ -37,6 +43,9 @@ import { refundAccountFor } from "./refund-wallet"; * longer than RECOVERY_DELAY (the promised window, registry P3) warn — the deposit * should be forwarded or recovering by then; past TRIGGER_DELAY (the * permissionless-trigger delay, registry P4) they error — a keeper-outage signal. + * An unswapped payment marked for recovery whose EURe is still on a clone below + * MIN_SWAP_FLOOR errors too: the contract cannot recover it, so the operator refunds + * it by hand (one whose funds already left the clone only warns: reconcile). * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault * (docs/architecture-monerium-b2b-onramp.md, fees section); a vault that cannot cover a * below-floor swap makes the keeper defer, so runway problems surface here first. @@ -383,6 +392,76 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }) ]); if (eureBalance < minSwapFloor && usdcBalance === 0n) { + // Below MIN_SWAP_FLOOR the contract arms no batch, so `recover` can never run for a + // payment marked for the refund path (one already recovered is the refund monitor's). + const marked = await MoneriumFiatDeposit.count({ + where: { accountId: account.id, status: MoneriumFiatDepositStatus.Recovering } + }); + if (marked === 0) continue; + // Every deposit whose EURe the ledger may still place on the clone: settling, marked, or + // refunded (a manual refund leaves its EURe there; a confirmed `recover` took it away). + const deposits = await MoneriumFiatDeposit.findAll({ + attributes: ["id", "amountRaw", "status"], + where: { + accountId: account.id, + status: { + [Op.in]: [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Converting, + MoneriumFiatDepositStatus.Recovering, + MoneriumFiatDepositStatus.Refunded + ] + } + } + }); + const executions = await MoneriumConversionExecution.findAll({ + attributes: ["depositId", "eureInRaw", "kind", "status"], + where: { + depositId: { [Op.in]: deposits.map(deposit => deposit.id) }, + [Op.or]: [ + { + kind: MoneriumConversionExecutionKind.Recover, + status: { [Op.in]: [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed] } + }, + { kind: MoneriumConversionExecutionKind.Swap, status: MoneriumConversionExecutionStatus.Confirmed } + ] + } + }); + const swapsOf = (deposit: MoneriumFiatDeposit) => + executions.filter(e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Swap); + const recoverOf = (deposit: MoneriumFiatDeposit, statuses: MoneriumConversionExecutionStatus[]) => + executions.some( + e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Recover && statuses.includes(e.status) + ); + const stuck = deposits.filter( + deposit => + deposit.status === MoneriumFiatDepositStatus.Recovering && + !recoverOf(deposit, [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) + ); + // Only an unswapped payment is refunded by hand, and only while the clone still holds + // all the EURe the ledger places there (every payment's unswapped, unrecovered rest); + // anything less means EURe left by a path the ledger does not record (the permissionless + // swap/forwardAll), so refunding could pay twice. + const unswapped = stuck.filter(deposit => swapsOf(deposit).length === 0); + const ledgerEure = deposits + .filter(deposit => !recoverOf(deposit, [MoneriumConversionExecutionStatus.Confirmed])) + .reduce((sum, deposit) => sum + settlementState(deposit, swapsOf(deposit)).remainingEureRaw, 0n); + const onClone = ledgerEure <= eureBalance ? unswapped : []; + const elsewhere = stuck.filter(deposit => !onClone.includes(deposit)); + if (onClone.length > 0) { + logger.error( + `monerium-b2b: REFUND NEEDS OPERATOR — deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ` + + `${forwarder} (account ${account.id}) are marked for recovery, but the clone holds ${eureBalance} EURe, below ` + + "MIN_SWAP_FLOOR, so the contract cannot recover them: refund each by hand as a redeem from the client's refund " + + "wallet with memo vortex-refund:, then close it as refunded at once (runbook §2.7)" + ); + } + if (elsewhere.length > 0) { + logger.warn( + `monerium-b2b: deposit(s) ${elsewhere.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + + `(account ${account.id}) are marked for recovery but their funds are no longer on the clone: reconcile, do not refund` + ); + } continue; } const severity = classifyStranding(batchOpenedAt, BigInt(recoveryDelaySeconds), triggerDelay, now); diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 8c7081310..5fdfe1419 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -3054,10 +3054,10 @@ export interface components { txHash: string | null; /** @description Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded. */ usdcNetRaw: string; - /** @description Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. */ + /** @description Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. */ waiting: { /** @enum {string} */ - reason: "monerium_pending" | "account_not_active" | "oracle_unavailable" | "reference_unavailable" | "reference_out_of_band" | "no_route" | "below_floor"; + reason: "monerium_pending" | "account_not_active" | "oracle_unavailable" | "reference_unavailable" | "reference_out_of_band" | "no_route" | "below_floor" | "below_minimum"; /** Format: date-time */ since: string; } | null; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 456311b7b..70c2632ce 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2787,7 +2787,7 @@ "type": "string" }, "waiting": { - "description": "Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise.", + "description": "Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise.", "properties": { "reason": { "enum": [ @@ -2797,7 +2797,8 @@ "reference_unavailable", "reference_out_of_band", "no_route", - "below_floor" + "below_floor", + "below_minimum" ], "type": "string" }, diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 21f7783cd..bddc7e09c 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -265,7 +265,7 @@ Fired whenever anything about a deposit changes: the payment arrives at the EUR - **IDs:** `depositId`, `accountId`, `profileId` (your managed child, the `X-Managed-Profile-Id` value), `externalSubjectId` (your own client reference), and `moneriumProfileId` and `moneriumOrderId` (the EUR provider's IDs for the client and for the incoming payment). Every transaction hash is included: the mint (`txHash`), each chunk, the forward, and on a refund the recovery transaction and the provider's redemption order. - **Amounts:** `amount` is the EUR amount to the cent and `amountRaw` the same in 18-decimal base units. Each chunk carries the EURe it converted and its net USDC; the top-level `usdcNetRaw` is the sum of the confirmed chunks, which the single transfer delivers. - **Timestamps:** `receivedAt`, `mintedAt` (the conversion window counts from here), each chunk's `sentAt` and `confirmedAt`, `deliveredAt`, and on a refund `refund.startedAt` and `refund.refundedAt`. -- **Hold status:** `waiting` is set while the deposit waits. `monerium_pending` means the EUR provider has not minted it yet: it is minting or under a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can, and a payment still waiting when the promised conversion window ends is refunded), otherwise the reason Vortex is holding the next chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor`, when the market is below the client's floor by more than the subsidy currently allows. +- **Hold status:** `waiting` is set while the deposit waits. `monerium_pending` means the EUR provider has not minted it yet: it is minting or under a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor`, when the market is below the client's floor by more than the subsidy currently allows. - **Failure status:** `rejectedReason` holds the provider's reason when it returned the payment before minting (`status` is then `returned`). `refund.reason` says why a deposit is refunded: `window_missed`, `compliance`, `incident`, or `operator`. `eventId` is unique per snapshot. If retries deliver an older snapshot after a newer one, keep the one with the later `timestamp`. diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index f77f4ce19..5be30ac1e 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -199,7 +199,7 @@ X-Managed-Profile-Id: **Sandbox specifics** - **Network.** Sandbox converts and delivers on Ethereum Sepolia; production uses Ethereum mainnet. The destination must be an Ethereum address the client controls, and it is fixed for the life of the account. -- **Minimum.** Payments from EUR 1 are converted; a smaller payment is refunded after the refund window. +- **Minimum.** Payments from EUR 1 are converted. A smaller payment is not converted and never holds back later payments; its `waiting.reason` is `below_minimum`. After the refund window it enters the refund path (`recovering`), but the forwarding contract cannot move an amount below EUR 1 on its own, so operations usually refund it by hand and it can take longer than other refunds. - **Refund window.** A payment that is not converted within 15 minutes of its mint (two hours by default in production), for example because the account was not active yet, is refunded in full to the account it came from (operations process the refund by hand where automatic refunds are off). The EUR provider pays refunds out only for profiles it has approved. - **Webhook signatures.** Sandbox signs deliveries with its own key: verify them against `https://api-sandbox.vortexfinance.co/v1/public-key`, not the production key. See [Webhooks](https://api-docs.vortexfinance.co/webhooks). diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 3cc932995..87df99279 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -600,7 +600,7 @@ DepositSnapshot: { txHash: null | string; usdcNetRaw: string; waiting: null | { - reason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + reason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; since: string; }; } @@ -652,14 +652,14 @@ DepositUpdatedWebhookPayload: { txHash: null | string; usdcNetRaw: string; waiting: null | { - reason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + reason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; since: string; }; }; timestamp: string; } -DepositWaitingReason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable" +DepositWaitingReason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable" DepositWebhookPayloadBase: { accountId: string; @@ -2684,7 +2684,7 @@ WebhookDeliveryAttempt: { txHash: null | string; usdcNetRaw: string; waiting: null | { - reason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + reason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; since: string; }; }; @@ -2841,7 +2841,7 @@ WebhookPayload: { txHash: null | string; usdcNetRaw: string; waiting: null | { - reason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + reason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; since: string; }; }; diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index b3d080083..c9836304d 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -361,7 +361,19 @@ and sends at most one transaction per account per cycle: per deposit after the forward is deep enough, with `conversions[]` per chunk and `forwardTxHash`. - **A remainder below `minSwapAmount`** (registry P6) cannot be swapped; it waits for - the refund path rather than merging with the next deposit. + the refund path (`waiting.reason` `below_minimum`) rather than merging with the next + deposit, and the keeper passes over it, so it holds back no younger deposit. A payment + below the immutable `MIN_SWAP_FLOOR` never arms the batch marker (`poke` refuses, by + design against dust), so `recover` cannot move it unless a larger balance keeps the + marker open for `RECOVERY_DELAY`; once marked `recovering` on a clone below the floor, + the stranded-balance monitor raises REFUND NEEDS OPERATOR and operations refund it by + hand from the client's refund wallet under the memo `vortex-refund:` (so a + later keeper `recover` cannot pay it twice) and close it `refunded` (internal B2B + runbook §2.7). A remainder left after chunk swaps is recoverable as usual: its USDC + keeps the marker armed. Every younger deposit's `forward` re-times the marker, so a + passed-over remainder at or above the floor (possible only once `minSwapAmount` is + raised above `MIN_SWAP_FLOOR`) becomes recoverable only after a forward-free + `RECOVERY_DELAY`. - **The refund path.** A deposit marked `recovering` — by an operator through the admin endpoint, or once automated by the missed window — is moved off the clone with `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the @@ -502,7 +514,13 @@ read-only — no keys, no transactions: 3. **Stranded-balance monitor.** Forwarders holding EURe or USDC whose batch marker has been open longer than `RECOVERY_DELAY` warn (the promised window was missed: forward or recover) and longer than `TRIGGER_DELAY` error (the permissionless path is live — - a keeper-outage signal; funds are never at risk). + a keeper-outage signal; funds are never at risk). An unswapped deposit marked + `recovering` without a pending or confirmed `recover`, on a clone below + `MIN_SWAP_FLOOR` that still holds all the EURe the ledger places there (every + deposit's unswapped, unrecovered rest, a manual refund's EURe included), raises REFUND + NEEDS OPERATOR (manual refund under its memo, then close it `refunded`, internal B2B + runbook §2.7); otherwise its funds may have left the clone by an unrecorded path, so + it warns "reconcile, do not refund". 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 377e03340..f11f8bf90 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,7 +39,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per the B2B operations runbook). 2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on every enabled factory route at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline); the best route decides. Settlement enforces `SLIPPAGE_BPS` on the client's net, so a raw impact above it means every keeper swap of that size draws a vault subsidy (deferring past the per-swap cap) and the permissionless path would revert; at `minSwapAmount` size that logs the error-level DEPTH BELOW FLOOR line, at `perSwapCap` size a warning (`classifyExecutableDepth`). The keeper's own `projectSwap` deferral remains the authoritative gate. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. -3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe or any USDC whose on-chain batch marker has been open longer than `RECOVERY_DELAY` (2 h, registry P3) warn — the promised window was missed and the deposit should be forwarded or recovering; past `TRIGGER_DELAY` they error (the permissionless swap/forward path is then live — a keeper-outage signal, not a fund-risk signal). +3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe or any USDC whose on-chain batch marker has been open longer than `RECOVERY_DELAY` (2 h, registry P3) warn — the promised window was missed and the deposit should be forwarded or recovering; past `TRIGGER_DELAY` they error (the permissionless swap/forward path is then live — a keeper-outage signal, not a fund-risk signal). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line, and the operator refunds it by hand as a redeem from the client's refund wallet with memo `vortex-refund:` (the orchestrator's exactly-once identity, so a later keeper `recover` cannot pay twice) and closes it `refunded`. It fires only while the clone holds at least all the EURe the ledger places there: every minted, converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a manual refund leaves its EURe on the clone). A swapped deposit, or any shortfall, means funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "reconcile, do not refund". 4. **Association monitor (S1 detective control)** — per active account, re-reads linked addresses and IBANs scoped to the exact mapped profile and configured chain, then error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). The only address besides the forwarder that is expected on the profile is the client's own derived refund wallet (`refundAccountFor(profileId)`, linked at onboarding); any other address still alerts, and with no refund seed configured no wallet is excused. This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. 5. **Config reconciliation (R07)** — first requires the clone's immutable `FACTORY()` to equal the configured trusted factory, then reads `implementation()` and `isForwarder()` only from that trusted address. A mismatch is an error and no mutable fields are reconciled. For trusted clones, timelocked fee-policy changes are authorized transitions reconciled with a version bump; a destination change (the clone has no setter), proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 084e12926..fd44b2e4c 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -188,8 +188,10 @@ export interface DepositReturnedWebhookPayload { /** * Why a deposit is waiting: `monerium_pending` until Monerium mints it (minting or a * compliance review, which Monerium does not tell apart), `account_not_active` while the - * account cannot convert (not activated yet, suspended, or paused for dormancy), otherwise - * the reason the keeper is holding the next conversion chunk. + * account cannot convert (not activated yet, suspended, or paused for dormancy), + * `below_minimum` while the unconverted amount is below the minimum swap (it waits for the + * refund path and holds back no later deposit), otherwise the reason the keeper is holding + * the next conversion chunk. */ export type DepositWaitingReason = | "monerium_pending" @@ -198,7 +200,8 @@ export type DepositWaitingReason = | "reference_unavailable" | "reference_out_of_band" | "no_route" - | "below_floor"; + | "below_floor" + | "below_minimum"; /** Why a deposit entered the refund path. */ export type DepositRefundReason = "window_missed" | "compliance" | "incident" | "operator";