From 9ed475a416627a6054569b836657bb466587a6ea Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 12:01:14 +0200 Subject: [PATCH 01/11] fix(api): finish a resumed Uniswap swap that already settled The swap executor checked balance, allowance and quote before reaching the idempotent broadcast. A mined swap consumes the allowance, so a crash between settlement and phase completion made every retry pause the ramp for reconciliation although the swap had succeeded. A successful receipt for the deterministic hash now skips the pre-flight checks. --- .../__tests__/uniswap-v3-fixed-swap.test.ts | 22 +++++++++ .../phases/uniswap-v3-fixed-swap/execution.ts | 45 ++++++++++--------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts index 8fc252e83..c010e0cae 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts @@ -468,6 +468,28 @@ describe("fixed Polygon Uniswap V3 execution failure branches", () => { expect(operationAttempts).toEqual([]); }); + it("finishes a resumed swap whose transaction already settled instead of pausing", async () => { + // Crash after the swap was mined: the allowance is consumed and the EURe is gone, but the + // deterministic hash already has a successful receipt. + const sendRawTransaction = mock(async (): Promise<`0x${string}`> => { + throw new Error("must not resend a settled swap"); + }); + const simulateTransaction = mock(async () => { + throw new Error("must not simulate a settled swap"); + }); + const error = await runSwap({ + getAllowance: async () => 0n, + getBalance: async token => (token === POLYGON_EURE ? 0n : 116_000_000n), + getReceipt: async () => ({ status: "success" }), + sendRawTransaction, + simulateTransaction + }); + expect(error).toBeNull(); + expect(sendRawTransaction).not.toHaveBeenCalled(); + expect(simulateTransaction).not.toHaveBeenCalled(); + expect(operationAttempts).toEqual([]); + }); + it("retries later when the live quote moved below the soft minimum", async () => { const error = await runSwap({ quote: async () => 113_679_999n }); expect(error).toBeInstanceOf(RecoverablePhaseError); diff --git a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts index e6e798f0d..fa6c82c53 100644 --- a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts @@ -180,27 +180,32 @@ export class UniswapSwapExecutor extends UniswapV3Executor { const unsigned = this.findTransaction(state.unsignedTxs, this.getPhaseName(), "swap blueprint"); await validatePresignedEvmTransactionAgainstUnsigned(signed, unsigned); const transaction = await validateUniswapSwap(signed, expectation); - if (BigInt(expectation.deadline) <= BigInt(Math.floor(Date.now() / 1000))) { - throw this.createRecoverableError("Uniswap fixed swap signature expired before settlement"); - } - await dependencies.verifyDeployment(); - const [inputBalance, allowance, currentQuote] = await Promise.all([ - dependencies.getBalance(POLYGON_EURE, expectation.signer), - dependencies.getAllowance(expectation.signer), - dependencies.quote(BigInt(expectation.amountInRaw)) - ]); - if (inputBalance < BigInt(expectation.amountInRaw)) { - throw this.createRecoverableError("Uniswap fixed swap input EURe has not reached the ephemeral account"); - } - if (allowance !== BigInt(expectation.amountInRaw)) { - throw this.createReconciliationRequiredError( - `Uniswap fixed swap allowance ${allowance} does not match ${expectation.amountInRaw}` - ); - } - if (currentQuote < BigInt(preparation.softMinimumOutputRaw)) { - throw this.createRecoverableError("Uniswap fixed swap quote moved below its soft minimum"); + // A swap that already settled on-chain has consumed the allowance, so the pre-flight checks can + // never pass again; skip them and let broadcast() recognise the mined transaction. + const settled = (await dependencies.getReceipt(keccak256(transaction)))?.status === "success"; + if (!settled) { + if (BigInt(expectation.deadline) <= BigInt(Math.floor(Date.now() / 1000))) { + throw this.createRecoverableError("Uniswap fixed swap signature expired before settlement"); + } + await dependencies.verifyDeployment(); + const [inputBalance, allowance, currentQuote] = await Promise.all([ + dependencies.getBalance(POLYGON_EURE, expectation.signer), + dependencies.getAllowance(expectation.signer), + dependencies.quote(BigInt(expectation.amountInRaw)) + ]); + if (inputBalance < BigInt(expectation.amountInRaw)) { + throw this.createRecoverableError("Uniswap fixed swap input EURe has not reached the ephemeral account"); + } + if (allowance !== BigInt(expectation.amountInRaw)) { + throw this.createReconciliationRequiredError( + `Uniswap fixed swap allowance ${allowance} does not match ${expectation.amountInRaw}` + ); + } + if (currentQuote < BigInt(preparation.softMinimumOutputRaw)) { + throw this.createRecoverableError("Uniswap fixed swap quote moved below its soft minimum"); + } + await dependencies.simulateTransaction(transaction); } - await dependencies.simulateTransaction(transaction); await this.broadcast(state, transaction, dependencies, signal); const [remainingAllowance, outputBalance] = await Promise.all([ dependencies.getAllowance(expectation.signer), From 5d891de3564fd485ebadcaf7aed05497e4aa7906 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 12:01:15 +0200 Subject: [PATCH 02/11] fix(frontend): return to the quote when the Monerium reconnect is cancelled The completion guard only looked at the APPROVED status, so cancelling the reconnect prompt of an approved profile with a lost session moved on to wallet linking, which failed on the same reauthentication error. --- apps/frontend/src/machines/kyc.states.ts | 8 ++++++-- .../src/machines/ramp.machine.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 651ccfee7..52e1212b1 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -189,15 +189,19 @@ export const kycStateNode = { onDone: [ { actions: assign({ moneriumCallback: undefined }), - guard: ({ event }: { event: DoneActorEvent }) => event.output.status === "APPROVED", + // An approved profile whose backend session is lost closes with rampError; the wallet step would only fail again. + guard: ({ event }: { event: DoneActorEvent }) => + event.output.status === "APPROVED" && !event.output.rampError, target: "MoneriumWallet" }, { - // Closed before approval (in review, rejected, cancelled): keep the quote, explain, and let the user retry. + // Closed before approval (in review, rejected, cancelled) or instead of reconnecting: keep the quote, + // explain, and let the user retry. actions: [ clearSigningPhase, assign({ initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => + event.output.rampError?.message || event.output.error?.message || (event.output.status ? "Monerium has not approved your verification yet." : undefined), moneriumCallback: undefined diff --git a/apps/frontend/src/machines/ramp.machine.test.ts b/apps/frontend/src/machines/ramp.machine.test.ts index 56e4ce9f6..5c174398c 100644 --- a/apps/frontend/src/machines/ramp.machine.test.ts +++ b/apps/frontend/src/machines/ramp.machine.test.ts @@ -535,6 +535,25 @@ describe("rampMachine", () => { expect(actor.getSnapshot().context.initializeFailedMessage).toBe("Monerium has not approved your verification yet."); }); + it("returns to QuoteReady instead of the wallet step when the user cancels the Monerium reconnect prompt", async () => { + // An approved profile whose backend OAuth session is gone closes with its status still APPROVED. + const rampError = { code: "MONERIUM_REAUTHENTICATION_REQUIRED", message: "Monerium reauthentication is required" }; + const actor = createRampActor({ + moneriumKyc: stubMoneriumKyc({ customerType: "individual", rampError, status: "APPROVED" }), + moneriumWallet: stubMoneriumWallet(), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + await waitFor(actor, s => s.matches({ KYC: "Monerium" })); + + finishChild(actor, "moneriumKyc"); + await waitFor(actor, s => s.matches("QuoteReady")); + expect(actor.getSnapshot().context.initializeFailedMessage).toBe(rampError.message); + expect(actor.getSnapshot().children.moneriumWallet).toBeUndefined(); + }); + it("an unlinked wallet returns to QuoteReady with the wallet step's message", async () => { const actor = createRampActor({ moneriumKyc: stubMoneriumKyc(), From f7d8c5efdd5f65af185b8e55cddd9079668d21c0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 12:01:15 +0200 Subject: [PATCH 03/11] fix(api): log Monerium token refreshes that fail upstream A 5xx from the token endpoint keeps the session for the next attempt on purpose, but nothing logged it, so a sustained outage on an expired token was indistinguishable from a blip. --- .../monerium/monerium.service.test.ts | 43 ++++++++++++++++++- .../api/services/monerium/monerium.service.ts | 12 ++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/monerium/monerium.service.test.ts b/apps/api/src/api/services/monerium/monerium.service.test.ts index f9cd5b1ea..cf6cae878 100644 --- a/apps/api/src/api/services/monerium/monerium.service.test.ts +++ b/apps/api/src/api/services/monerium/monerium.service.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; // Load this shared consumer before the module mocks below; Bun does not unregister mock.module // replacements, and the API suite may import transfer eligibility after this file. import "../recipients/transfer-eligibility.service"; @@ -8,6 +8,7 @@ import "./wallet"; // module mocks are process-wide and would poison later test files (e.g. integration tests // that need the real sequelize instance and models). import * as databaseNamespace from "../../../config/database"; +import logger from "../../../config/logger"; import * as kycCaseNamespace from "../../../models/kycCase.model"; import * as providerCustomerNamespace from "../../../models/providerCustomer.model"; import * as customerEntityNamespace from "../customer-entity.service"; @@ -368,6 +369,46 @@ describe("Monerium OAuth", () => { expect(tokenCalls).toBe(2); }); + it("keeps the session and logs a warning when the refresh grant fails upstream", async () => { + let tokenCalls = 0; + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/auth/token")) { + tokenCalls += 1; + return tokenCalls === 1 + ? jsonResponse({ access_token: "old-access", expires_in: 1, refresh_token: "old-refresh" }) + : new Response("upstream unavailable", { status: 503 }); + } + if (url.endsWith("/auth/context")) { + return jsonResponse({ + email: "owner@example.com", + profiles: [{ id: "profile-a", kind: "personal" }], + userId: "monerium-user-a" + }); + } + return jsonResponse({ id: "profile-a", kind: "personal", state: "pending" }); + }) as unknown as typeof fetch; + const warn = spyOn(logger, "warn").mockImplementation(() => logger); + + try { + const { authorizationUrl } = await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + const state = new URL(authorizationUrl).searchParams.get("state") as string; + await service.completeMoneriumOAuth("owner", "authorization-code", state); + + await expect(service.getMoneriumStatus("owner", "individual")).rejects.toMatchObject({ status: 502 }); + // The session survives an outage: the next call retries the refresh instead of demanding reauthentication. + await expect(service.getMoneriumStatus("owner", "individual")).rejects.toMatchObject({ status: 502 }); + expect(tokenCalls).toBe(3); + expect(warn).toHaveBeenCalledTimes(2); + const logged = JSON.stringify(warn.mock.calls); + expect(logged).toContain("token refresh"); + expect(logged).toContain("503"); + expect(logged).not.toContain("old-refresh"); + } finally { + warn.mockRestore(); + } + }); + it("asks for reauthentication when Monerium revokes an otherwise unexpired access token", async () => { let profileReads = 0; globalThis.fetch = mock(async (input: string | URL | Request) => { diff --git a/apps/api/src/api/services/monerium/monerium.service.ts b/apps/api/src/api/services/monerium/monerium.service.ts index 5033d6cf5..147ea65c6 100644 --- a/apps/api/src/api/services/monerium/monerium.service.ts +++ b/apps/api/src/api/services/monerium/monerium.service.ts @@ -2,6 +2,7 @@ import crypto from "crypto"; import httpStatus from "http-status"; import NodeCache from "node-cache"; import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import KycCase from "../../../models/kycCase.model"; import ProviderCustomer, { @@ -234,6 +235,17 @@ async function getValidCredentials(customerEntityId: string, customerType: Provi type: MONERIUM_REAUTHENTICATION_REQUIRED }); } + // An upstream failure keeps the session for the next attempt, but must not stay invisible: a + // sustained outage on a truly expired token would otherwise read as an endless 502. + const reason = + error instanceof MoneriumUpstreamError + ? `upstream status ${error.upstreamStatus ?? "unknown"}` + : error instanceof Error + ? error.message + : String(error); + logger.warn( + `MoneriumOAuth: token refresh for ${customerType} entity ${customerEntityId} failed (${reason}); keeping the session for retry` + ); throw error; } ); From 6a6e92349ab7aa45d28a42d4b2d785b28b1c4763 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 12:01:15 +0200 Subject: [PATCH 04/11] fix(api): hold the profile lock while linking a Monerium wallet IBAN moves and issue registration already serialise on the profile advisory lock; the wallet link mutated the same address and IBAN lists without it. --- .../src/api/services/monerium/wallet.test.ts | 27 +++++++++ apps/api/src/api/services/monerium/wallet.ts | 60 +++++++++++-------- 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/apps/api/src/api/services/monerium/wallet.test.ts b/apps/api/src/api/services/monerium/wallet.test.ts index 09bd519ff..edda8352a 100644 --- a/apps/api/src/api/services/monerium/wallet.test.ts +++ b/apps/api/src/api/services/monerium/wallet.test.ts @@ -165,6 +165,33 @@ describe("linkMoneriumWallet", () => { expect(error.message).toContain("Contract wallets are not supported"); expect(monerium.linkAddress).not.toHaveBeenCalled(); }); + + it("holds the profile lock while linking the address and requesting the IBAN", async () => { + const monerium = client(); + const order: string[] = []; + monerium.linkAddress.mockImplementation(async () => { + order.push("link"); + return { httpStatus: 201 as const }; + }); + monerium.requestIban.mockImplementation(async () => { + order.push("iban"); + return { httpStatus: 202 as const }; + }); + const runWithProfileLock = async (profileId: string, work: (transaction: Transaction) => Promise): Promise => { + order.push(`lock:${profileId}`); + const result = await work(undefined as unknown as Transaction); + order.push("unlock"); + return result; + }; + + await linkMoneriumWallet( + "user-1", + { address: OWNER.address, chain: "polygon", signature: await ownerSignature() }, + deps(monerium, { runWithProfileLock }) + ); + + expect(order).toEqual([`lock:${PROFILE_ID}`, "link", "iban", "unlock"]); + }); }); describe("moveMoneriumIban", () => { diff --git a/apps/api/src/api/services/monerium/wallet.ts b/apps/api/src/api/services/monerium/wallet.ts index 1ca2f3014..9e07a7bd6 100644 --- a/apps/api/src/api/services/monerium/wallet.ts +++ b/apps/api/src/api/services/monerium/wallet.ts @@ -146,33 +146,43 @@ export async function linkMoneriumWallet( }); } + const signature = input.signature; const identity = await dependencies.resolveIdentity(userId, undefined, input.customerType); - const before = await readDestinations(identity, chain); - if (!before.addresses.some(entry => sameAddress(entry.address, address))) { - await identity.client.linkAddress({ - address, - chain, - message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, - profile: identity.profileId, - signature: input.signature - }); - logger.info(`MoneriumWallet: linked ${address} on ${chain} through the ${identity.source} app`); - } - - if (before.ibans.some(entry => entry.chain === chain && sameAddress(entry.address, address))) { - return { address, chain, iban: "provisioned" }; - } - if (before.ibans.length > 0) return { address, chain, iban: "elsewhere" }; + // Serialise with IBAN moves and issue registration on the same profile: both read the same + // address and IBAN lists this call mutates. + return (dependencies.runWithProfileLock ?? runWithProfileLock)( + identity.profileId, + async (): Promise => { + const before = await readDestinations(identity, chain); + if (!before.addresses.some(entry => sameAddress(entry.address, address))) { + await identity.client.linkAddress({ + address, + chain, + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: identity.profileId, + signature + }); + logger.info(`MoneriumWallet: linked ${address} on ${chain} through the ${identity.source} app`); + } - try { - await identity.client.requestIban({ address, chain }); - } catch (error) { - // Monerium keeps one IBAN per profile and answers 400 when one is already requested. - if (!(error instanceof MoneriumApiError && error.status === 400)) throw error; - logger.warn(`MoneriumWallet: POST /ibans answered 400 for ${address} on ${chain}; assuming an IBAN is already requested`); - } - logger.info(`MoneriumWallet: requested an IBAN for ${address} on ${chain}`); - return { address, chain, iban: "pending" }; + if (before.ibans.some(entry => entry.chain === chain && sameAddress(entry.address, address))) { + return { address, chain, iban: "provisioned" }; + } + if (before.ibans.length > 0) return { address, chain, iban: "elsewhere" }; + + try { + await identity.client.requestIban({ address, chain }); + } catch (error) { + // Monerium keeps one IBAN per profile and answers 400 when one is already requested. + if (!(error instanceof MoneriumApiError && error.status === 400)) throw error; + logger.warn( + `MoneriumWallet: POST /ibans answered 400 for ${address} on ${chain}; assuming an IBAN is already requested` + ); + } + logger.info(`MoneriumWallet: requested an IBAN for ${address} on ${chain}`); + return { address, chain, iban: "pending" }; + } + ); } /** Moves the profile's single IBAN to an already-linked address. Only ever called on the owner's explicit request. */ From eccd4a5cd3564bb3b074c6d7973d7f4177321605 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 12:01:15 +0200 Subject: [PATCH 05/11] fix(dashboard): stop polling Monerium status after three minutes The widget bounds the same IBAN wait; the dashboard polled forever. --- .../monerium/MoneriumWalletLinkFlow.test.ts | 34 ++++++++++++++++++- .../monerium/MoneriumWalletLinkFlow.tsx | 29 ++++++++++------ .../onboarding/monerium/walletStep.ts | 17 ++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.test.ts b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.test.ts index 192122101..827c1b748 100644 --- a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.test.ts +++ b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import type { MoneriumRampReadiness, MoneriumWalletLinkResult } from "@vortexfi/kyc"; -import { moneriumWalletLinkRequired, moneriumWalletStep } from "./walletStep"; +import { + MONERIUM_STATUS_MAX_POLLS, + MONERIUM_STATUS_POLL_INTERVAL_MS, + moneriumStatusPollInterval, + moneriumWalletLinkRequired, + moneriumWalletStep +} from "./walletStep"; const OLD = "0x1111111111111111111111111111111111111111"; const NEW = "0x2222222222222222222222222222222222222222"; @@ -24,6 +30,32 @@ describe("Monerium wallet step", () => { }); }); +describe("Monerium status polling", () => { + const pending: MoneriumRampReadiness = { ...ramp, iban: "missing", linkedAddress: null }; + + it("polls while the IBAN is not provisioned to the connected wallet", () => { + assert.equal( + moneriumStatusPollInterval({ address: NEW, error: null, polls: 3, ramp: pending }), + MONERIUM_STATUS_POLL_INTERVAL_MS + ); + assert.equal(moneriumStatusPollInterval({ address: NEW, error: null, polls: 3, ramp }), MONERIUM_STATUS_POLL_INTERVAL_MS); + assert.equal(moneriumStatusPollInterval({ address: OLD, error: null, polls: 3, ramp }), false); + }); + + it("stops on an error or without a wallet", () => { + assert.equal(moneriumStatusPollInterval({ address: NEW, error: new Error("boom"), polls: 0, ramp: pending }), false); + assert.equal(moneriumStatusPollInterval({ address: undefined, error: null, polls: 0, ramp: pending }), false); + }); + + it("gives up after the same bound as the widget instead of polling forever", () => { + assert.equal( + moneriumStatusPollInterval({ address: NEW, error: null, polls: MONERIUM_STATUS_MAX_POLLS - 1, ramp: pending }), + MONERIUM_STATUS_POLL_INTERVAL_MS + ); + assert.equal(moneriumStatusPollInterval({ address: NEW, error: null, polls: MONERIUM_STATUS_MAX_POLLS, ramp: pending }), false); + }); +}); + describe("Monerium wallet link requirement", () => { it("yields to reauthentication when readiness could not be read", () => { assert.equal(moneriumWalletLinkRequired({ ramp: null, reauthenticationRequired: true, status: "approved" }), false); diff --git a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx index d0045e4df..46685812d 100644 --- a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx +++ b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx @@ -11,7 +11,7 @@ import type { OnboardingStatus } from "@/domain/types"; import { ONBOARDING_STATUS_QUERY_KEY } from "@/hooks/useApprovedCorridors"; import { apiClient } from "@/services/api/api-client"; import { signMoneriumWalletLinkMessage } from "@/services/transactions/userSigning"; -import { moneriumWalletStep } from "./walletStep"; +import { MONERIUM_STATUS_MAX_POLLS, moneriumStatusPollInterval, moneriumWalletStep } from "./walletStep"; const api = createMoneriumKycApi(apiClient); export const MONERIUM_STATUS_QUERY_KEY = ["monerium-status"] as const; @@ -31,20 +31,21 @@ interface MoneriumWalletLinkFlowProps { export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: MoneriumWalletLinkFlowProps) { const queryClient = useQueryClient(); const { address } = useAccount(); + const statusQueryKey = [...MONERIUM_STATUS_QUERY_KEY, customerType]; const status = useQuery({ queryFn: () => api.getStatus(customerType), - queryKey: [...MONERIUM_STATUS_QUERY_KEY, customerType], - refetchInterval: query => { - const current = query.state.data?.ramp; - return query.state.error || - !address || - (current?.iban === "provisioned" && current.linkedAddress?.toLowerCase() === address.toLowerCase()) - ? false - : 5_000; - }, + queryKey: statusQueryKey, + refetchInterval: query => + moneriumStatusPollInterval({ + address, + error: query.state.error, + polls: query.state.dataUpdateCount, + ramp: query.state.data?.ramp + }), retry: false }); const ramp = status.data?.ramp; + const pollsExhausted = (queryClient.getQueryState(statusQueryKey)?.dataUpdateCount ?? 0) >= MONERIUM_STATUS_MAX_POLLS; function refresh() { queryClient.invalidateQueries({ queryKey: MONERIUM_STATUS_QUERY_KEY }); @@ -191,6 +192,14 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon

Waiting for Monerium to update this IBAN’s destination…

)} {failure &&

{failure.message}

} + {pollsExhausted && ( +

+ Monerium is taking longer than usual.{" "} + +

+ )} diff --git a/apps/dashboard/src/components/onboarding/monerium/walletStep.ts b/apps/dashboard/src/components/onboarding/monerium/walletStep.ts index 5c5adcee1..a7f738818 100644 --- a/apps/dashboard/src/components/onboarding/monerium/walletStep.ts +++ b/apps/dashboard/src/components/onboarding/monerium/walletStep.ts @@ -1,6 +1,23 @@ import type { MoneriumRampReadiness, MoneriumWalletLinkResult } from "@vortexfi/kyc"; import type { Onboarding } from "@/domain/types"; +export const MONERIUM_STATUS_POLL_INTERVAL_MS = 5_000; +/** 36 polls of 5 s, about three minutes: the same bound the widget's wallet machine applies. */ +export const MONERIUM_STATUS_MAX_POLLS = 36; + +/** Poll until the connected wallet holds the IBAN; stop on an error, without a wallet, or once the wait has run too long. */ +export function moneriumStatusPollInterval(input: { + address: string | undefined; + error: unknown; + polls: number; + ramp: MoneriumRampReadiness | undefined; +}): number | false { + if (input.error || !input.address || input.polls >= MONERIUM_STATUS_MAX_POLLS) return false; + const provisionedHere = + input.ramp?.iban === "provisioned" && input.ramp.linkedAddress?.toLowerCase() === input.address.toLowerCase(); + return provisionedHere ? false : MONERIUM_STATUS_POLL_INTERVAL_MS; +} + /** An approved profile still needs its pay-in wallet linked; a lost OAuth session must be reconnected first. */ export function moneriumWalletLinkRequired( onboarding: Pick | undefined From 6f0bf523363083f425f8b8e8ae9fe41fc941cb6e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 12:01:15 +0200 Subject: [PATCH 06/11] chore(api): drop the unused Mykobo fee import from the quote service --- apps/api/src/api/services/quote/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 091dda5ea..93f4230e9 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -25,7 +25,6 @@ import { SUPPORTED_CHAINS, validateChainSupport } from "../phases/blocks/core/helpers"; -import { MykoboFeeUnavailableError } from "../phases/blocks/core/mykobo-fee"; import { runBlockQuoteFlow } from "../phases/blocks/core/quote"; import { buildBlockQuoteResponse } from "../phases/blocks/core/quote-response"; import { BaseRampService } from "../ramp/base.service"; From 82aa7c04304db5e6797e911735a03751a9c79174 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 12:01:15 +0200 Subject: [PATCH 07/11] test(api): read the EURe address from the Monerium network map --- .../phases/blocks/__tests__/monerium-issue.executor.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.executor.test.ts index 1a1653dc4..083dc98e6 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.executor.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.executor.test.ts @@ -3,6 +3,7 @@ import { BalanceCheckError, BalanceCheckErrorType, Networks } from "@vortexfi/sh import Big from "big.js"; import { MoneriumOnrampMintExecutor } from "../phases/monerium-issue/execution"; import { MoneriumIssue } from "../phases/monerium-issue"; +import { MONERIUM_ISSUE_NETWORKS } from "../phases/monerium-issue/simulation"; const OWNER = "0x1212121212121212121212121212121212121212"; @@ -40,7 +41,7 @@ describe("MoneriumOnrampMintExecutor", () => { expect(await executor.executePhase(rampState)).toBe(rampState); expect(waitForBalance).toHaveBeenCalledWith( - "0x18ec0A6E18E5bc3784fDd3a3634b31245ab704F6", + MONERIUM_ISSUE_NETWORKS[Networks.Polygon].eureAddress, OWNER, "598", 5000, From 6ca8691daf9593be6907f2ab1df1d50d80c438eb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 12:01:16 +0200 Subject: [PATCH 08/11] docs(repo): document the Uniswap resume and wallet-link lock invariants --- docs/security-spec/05-integrations/monerium.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 4608a276f..5f7098517 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -102,10 +102,10 @@ recovery. 15. Production startup MUST fail without a Monerium auth-code client ID, exact callback URI, and explicit non-negative `MONERIUM_ISSUE_FEE_EUR`. The issue fee MUST NOT silently default to zero. The white-label credential pair is optional outside the B2B onramp; when it is absent, every Monerium read uses the user's OAuth token. Credentials MUST NOT be accepted from client requests. 16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's `monerium`/`eur` provider-customer binding for the requested `customerType` (`individual` or `business`); the widget supplies its individual type and the dashboard supplies the selected account type. With one bound profile, legacy type-less calls may use it; with more than one, they MUST fail with a public `409` `MONERIUM_CUSTOMER_TYPE_REQUIRED`. When white-label credentials are configured, the resolver MUST read the selected profile through the white-label app first and, only when that app answers `403` or `404`, through the user's backend-held OAuth token (`resolveMoneriumIdentity`); without them it MUST use the OAuth token directly. A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. Registration MUST hold transaction-scoped profile and owner advisory locks through the active-ramp check and ramp insert. It MUST reject registration with a public `409` while another Monerium ramp for the same owner is live (`findActiveMoneriumRampForOwner`: any non-terminal ramp except an unstarted one whose start window has closed), because permits for one owner share an ERC-2612 nonce and the mint executor attributes by balance delta. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. 17. Self-transfer registration MUST copy only owner, token, chain, and amount from trusted `monerium-issue` facts and MUST reject an owner that is also the EVM ephemeral. Its EURe permit and exact `transferFrom` MUST be independently validated and reconciled; strict presign completeness MUST require both the user-signed permit and ephemeral-signed transfer. A still-current permit MUST be consumed even when allowance already exists, while an advanced nonce or expired deadline may prove it non-replayable. Permit and transfer hashes MUST remain in namespaced block state, and successful execution MUST verify receipts and the exact allowance reduction. -18. The Polygon conversion MUST verify each pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, through the pinned packed path, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. +18. The Polygon conversion MUST verify each pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, through the pinned packed path, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. When the swap's deterministic transaction hash already has a successful receipt, execution MUST skip the pre-broadcast deadline, deployment, balance, allowance, and quote checks (the mined swap has consumed the allowance) and proceed to that post-swap reconciliation, so a crash between settlement and phase completion resumes instead of pausing. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. 19. Issue execution MUST wait for `currentOwnerBalance >= persistedBaseline + quotedPostFeeEureRaw`. Timeouts and exhausted RPC reads are recoverable. Missing or malformed settlement facts are unrecoverable corruption. The executor MUST transfer only the quoted post-fee amount; excess EURe remains in the owner wallet. This non-deterministic attribution exception is accepted only under RISK-023. 20. The owner permit carries the same deadline as the downstream swap presign (`config.swap.deadlineMinutes`, one week) from transaction preparation; its spender is the ramp's own ephemeral and its value exact, so the window bounds time only. An expired permit or consumed nonce MUST stop automatic self-transfer unless a sufficient safe allowance remains, and MUST pause the ramp for reconciliation (not a recoverable retry) when no allowance remains; the API MUST NOT fabricate or broaden authorization. The absence of automatic reauthorization/recovery is accepted under RISK-024. -21. `POST /v1/monerium/wallet` MUST verify the EOA signature over the fixed ownership message server-side before any provider call, MUST reject addresses with deployed code, MUST select the same legal profile type as the status read, MUST link through the app that can read that profile, and MUST request its single IBAN only when it has none. `POST /v1/monerium/iban/move` MUST hold the same profile and current-owner advisory locks through the active-ramp check and provider mutation, and MUST reject with a public `409` while a live Monerium ramp still waits on the IBAN's current wallet, because the mint executor watches only the registered owner. Status reads (`GET /v1/monerium/status`, `GET /v1/onboarding/status`) MUST NOT mutate provider state; they report readiness for that legal type from the same list reads registration uses. OAuth-user API reads returning `401` at any point MUST surface `MONERIUM_REAUTHENTICATION_REQUIRED` so clients can reconnect. +21. `POST /v1/monerium/wallet` MUST verify the EOA signature over the fixed ownership message server-side before any provider call, MUST reject addresses with deployed code, MUST select the same legal profile type as the status read, MUST link through the app that can read that profile, MUST request its single IBAN only when it has none, and MUST hold the profile advisory lock through the list reads, the link, and the IBAN request so it serialises with IBAN moves and issue registration on that profile. `POST /v1/monerium/iban/move` MUST hold the same profile and current-owner advisory locks through the active-ramp check and provider mutation, and MUST reject with a public `409` while a live Monerium ramp still waits on the IBAN's current wallet, because the mint executor watches only the registered owner. Status reads (`GET /v1/monerium/status`, `GET /v1/onboarding/status`) MUST NOT mutate provider state; they report readiness for that legal type from the same list reads registration uses. OAuth-user API reads returning `401` at any point MUST surface `MONERIUM_REAUTHENTICATION_REQUIRED` so clients can reconnect. 22. An IBAN destination MUST change only through `POST /v1/monerium/iban/move`, an explicit request by the authenticated owner naming an address already linked on that chain, because it redirects the profile's future SEPA deposits. First-party UIs MUST ask for informed confirmation before that request and explain that other services using the IBAN may depend on the old wallet. The backend MUST NOT move an IBAN as a side effect of linking, status, or registration. 23. The OAuth redirect URI MUST come from the configured allowlist (`MONERIUM_REDIRECT_URI`, `MONERIUM_WIDGET_REDIRECT_URI`) selected by the `client` field, never from caller-supplied URLs, and MUST be bound into the OAuth transaction so the code exchange reuses the same exact URI. From 1396968b7a89eaefba4fbe1a4e585c2a0324a9ff Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 15:28:05 +0200 Subject: [PATCH 09/11] fix(api): skip the broadcast entirely for a settled Uniswap swap broadcast() re-reads the receipt and treats a transient RPC failure as not mined, so entering it after the resume lookup could resend the mined transaction. One lookup now decides the resume. --- .../phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts | 6 +++++- .../phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts | 5 +++-- docs/security-spec/05-integrations/monerium.md | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts index c010e0cae..a0e5a94ad 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts @@ -477,14 +477,18 @@ describe("fixed Polygon Uniswap V3 execution failure branches", () => { const simulateTransaction = mock(async () => { throw new Error("must not simulate a settled swap"); }); + const getReceipt = mock(async (): Promise<{ status: "success" }> => ({ status: "success" })); const error = await runSwap({ getAllowance: async () => 0n, getBalance: async token => (token === POLYGON_EURE ? 0n : 116_000_000n), - getReceipt: async () => ({ status: "success" }), + getReceipt, sendRawTransaction, simulateTransaction }); expect(error).toBeNull(); + // One lookup decides the resume; broadcast() is not entered, so a transient RPC failure on a + // second read cannot turn into a resend of the mined transaction. + expect(getReceipt).toHaveBeenCalledTimes(1); expect(sendRawTransaction).not.toHaveBeenCalled(); expect(simulateTransaction).not.toHaveBeenCalled(); expect(operationAttempts).toEqual([]); diff --git a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts index fa6c82c53..1ae1808e4 100644 --- a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts @@ -181,7 +181,8 @@ export class UniswapSwapExecutor extends UniswapV3Executor { await validatePresignedEvmTransactionAgainstUnsigned(signed, unsigned); const transaction = await validateUniswapSwap(signed, expectation); // A swap that already settled on-chain has consumed the allowance, so the pre-flight checks can - // never pass again; skip them and let broadcast() recognise the mined transaction. + // never pass again; skip them and the broadcast, which would otherwise re-read the receipt and, + // on a transient RPC failure, try to resend the mined transaction. const settled = (await dependencies.getReceipt(keccak256(transaction)))?.status === "success"; if (!settled) { if (BigInt(expectation.deadline) <= BigInt(Math.floor(Date.now() / 1000))) { @@ -205,8 +206,8 @@ export class UniswapSwapExecutor extends UniswapV3Executor { throw this.createRecoverableError("Uniswap fixed swap quote moved below its soft minimum"); } await dependencies.simulateTransaction(transaction); + await this.broadcast(state, transaction, dependencies, signal); } - await this.broadcast(state, transaction, dependencies, signal); const [remainingAllowance, outputBalance] = await Promise.all([ dependencies.getAllowance(expectation.signer), dependencies.getBalance(POLYGON_USDC, expectation.signer) diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 5f7098517..c2330b78e 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -102,7 +102,7 @@ recovery. 15. Production startup MUST fail without a Monerium auth-code client ID, exact callback URI, and explicit non-negative `MONERIUM_ISSUE_FEE_EUR`. The issue fee MUST NOT silently default to zero. The white-label credential pair is optional outside the B2B onramp; when it is absent, every Monerium read uses the user's OAuth token. Credentials MUST NOT be accepted from client requests. 16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's `monerium`/`eur` provider-customer binding for the requested `customerType` (`individual` or `business`); the widget supplies its individual type and the dashboard supplies the selected account type. With one bound profile, legacy type-less calls may use it; with more than one, they MUST fail with a public `409` `MONERIUM_CUSTOMER_TYPE_REQUIRED`. When white-label credentials are configured, the resolver MUST read the selected profile through the white-label app first and, only when that app answers `403` or `404`, through the user's backend-held OAuth token (`resolveMoneriumIdentity`); without them it MUST use the OAuth token directly. A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. Registration MUST hold transaction-scoped profile and owner advisory locks through the active-ramp check and ramp insert. It MUST reject registration with a public `409` while another Monerium ramp for the same owner is live (`findActiveMoneriumRampForOwner`: any non-terminal ramp except an unstarted one whose start window has closed), because permits for one owner share an ERC-2612 nonce and the mint executor attributes by balance delta. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. 17. Self-transfer registration MUST copy only owner, token, chain, and amount from trusted `monerium-issue` facts and MUST reject an owner that is also the EVM ephemeral. Its EURe permit and exact `transferFrom` MUST be independently validated and reconciled; strict presign completeness MUST require both the user-signed permit and ephemeral-signed transfer. A still-current permit MUST be consumed even when allowance already exists, while an advanced nonce or expired deadline may prove it non-replayable. Permit and transfer hashes MUST remain in namespaced block state, and successful execution MUST verify receipts and the exact allowance reduction. -18. The Polygon conversion MUST verify each pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, through the pinned packed path, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. When the swap's deterministic transaction hash already has a successful receipt, execution MUST skip the pre-broadcast deadline, deployment, balance, allowance, and quote checks (the mined swap has consumed the allowance) and proceed to that post-swap reconciliation, so a crash between settlement and phase completion resumes instead of pausing. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. +18. The Polygon conversion MUST verify each pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, through the pinned packed path, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. When the swap's deterministic transaction hash already has a successful receipt, execution MUST skip the pre-broadcast deadline, deployment, balance, allowance, and quote checks and the broadcast itself (the mined swap has consumed the allowance, and a second receipt read that fails transiently must not turn into a resend) and proceed to that post-swap reconciliation, so a crash between settlement and phase completion resumes instead of pausing. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. 19. Issue execution MUST wait for `currentOwnerBalance >= persistedBaseline + quotedPostFeeEureRaw`. Timeouts and exhausted RPC reads are recoverable. Missing or malformed settlement facts are unrecoverable corruption. The executor MUST transfer only the quoted post-fee amount; excess EURe remains in the owner wallet. This non-deterministic attribution exception is accepted only under RISK-023. 20. The owner permit carries the same deadline as the downstream swap presign (`config.swap.deadlineMinutes`, one week) from transaction preparation; its spender is the ramp's own ephemeral and its value exact, so the window bounds time only. An expired permit or consumed nonce MUST stop automatic self-transfer unless a sufficient safe allowance remains, and MUST pause the ramp for reconciliation (not a recoverable retry) when no allowance remains; the API MUST NOT fabricate or broaden authorization. The absence of automatic reauthorization/recovery is accepted under RISK-024. 21. `POST /v1/monerium/wallet` MUST verify the EOA signature over the fixed ownership message server-side before any provider call, MUST reject addresses with deployed code, MUST select the same legal profile type as the status read, MUST link through the app that can read that profile, MUST request its single IBAN only when it has none, and MUST hold the profile advisory lock through the list reads, the link, and the IBAN request so it serialises with IBAN moves and issue registration on that profile. `POST /v1/monerium/iban/move` MUST hold the same profile and current-owner advisory locks through the active-ramp check and provider mutation, and MUST reject with a public `409` while a live Monerium ramp still waits on the IBAN's current wallet, because the mint executor watches only the registered owner. Status reads (`GET /v1/monerium/status`, `GET /v1/onboarding/status`) MUST NOT mutate provider state; they report readiness for that legal type from the same list reads registration uses. OAuth-user API reads returning `401` at any point MUST surface `MONERIUM_REAUTHENTICATION_REQUIRED` so clients can reconnect. From da6885fef53613d8847f2d601c9db307473ff889 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 15:28:05 +0200 Subject: [PATCH 10/11] fix(dashboard): count Monerium status polls per provisioning attempt dataUpdateCount is cumulative for the cached query, so time spent before linking or a quick reopen consumed the budget. The baseline now restarts on open, after a successful link or move, and on Check again. --- .../monerium/MoneriumWalletLinkFlow.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx index 46685812d..7fed34e14 100644 --- a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx +++ b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createMoneriumKycApi, MoneriumAuthorizationRequiredError, type MoneriumCustomerType } from "@vortexfi/kyc"; import { AlertTriangle, CheckCircle2, Loader2, Wallet } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useAccount } from "wagmi"; import { ConnectWalletButton } from "@/components/layout/ConnectWalletButton"; import { Button } from "@/components/ui/button"; @@ -32,6 +32,10 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon const queryClient = useQueryClient(); const { address } = useAccount(); const statusQueryKey = [...MONERIUM_STATUS_QUERY_KEY, customerType]; + const fetchCount = () => queryClient.getQueryState(statusQueryKey)?.dataUpdateCount ?? 0; + // The poll budget belongs to one provisioning attempt: it restarts when the dialog opens, after a + // link or move succeeds, and on "Check again", not with the cached query's lifetime. + const [attemptStart, setAttemptStart] = useState(fetchCount); const status = useQuery({ queryFn: () => api.getStatus(customerType), queryKey: statusQueryKey, @@ -39,33 +43,38 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon moneriumStatusPollInterval({ address, error: query.state.error, - polls: query.state.dataUpdateCount, + polls: query.state.dataUpdateCount - attemptStart, ramp: query.state.data?.ramp }), retry: false }); const ramp = status.data?.ramp; - const pollsExhausted = (queryClient.getQueryState(statusQueryKey)?.dataUpdateCount ?? 0) >= MONERIUM_STATUS_MAX_POLLS; + const pollsExhausted = fetchCount() - attemptStart >= MONERIUM_STATUS_MAX_POLLS; function refresh() { queryClient.invalidateQueries({ queryKey: MONERIUM_STATUS_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: ONBOARDING_STATUS_QUERY_KEY }); } + function startAttempt() { + setAttemptStart(fetchCount()); + refresh(); + } + const link = useMutation({ mutationFn: async () => { if (!address || !ramp) throw new Error("Connect a wallet first"); const signature = await signMoneriumWalletLinkMessage(); return api.linkWallet({ address, chain: ramp.chain, customerType, signature }); }, - onSuccess: refresh + onSuccess: startAttempt }); const move = useMutation({ mutationFn: async () => { if (!address || !ramp) throw new Error("Connect a wallet first"); return api.moveIban({ address, chain: ramp.chain, customerType }); }, - onSuccess: refresh + onSuccess: startAttempt }); const reauthorize = useMutation({ mutationFn: () => api.startOAuth(customerType), @@ -195,7 +204,7 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon {pollsExhausted && (

Monerium is taking longer than usual.{" "} -

From 5d4549a3df048e62d80d76f5314da0ade70afbd4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 19 Sep 2026 15:36:11 +0200 Subject: [PATCH 11/11] fix(api): settle the Uniswap journal row from the resume receipt Skipping broadcast() also skipped runFinancialOperation, so a row left submitted by a crash between send and result persistence never became confirmed. The receipt read for the resume now feeds the journal's reconcile and perform callbacks, which confirms the row without a second RPC read or a resend; the receipt short-circuit that bypassed the journal is gone. --- .../__tests__/uniswap-v3-fixed-swap.test.ts | 63 +++++++++++++++---- .../phases/uniswap-v3-fixed-swap/execution.ts | 40 ++++++------ .../security-spec/05-integrations/monerium.md | 2 +- 3 files changed, 73 insertions(+), 32 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts index a0e5a94ad..1d0ba3005 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts @@ -28,13 +28,31 @@ import { const financialOperationReal = { ...financialOperationNamespace }; const operationAttempts: string[] = []; +/** Journal rows by attempt class; a seeded `submitted` row must be reconciled, never performed again. */ +const operationRows = new Map(); mock.module("../core/financial-operation", () => ({ ...financialOperationReal, requireFinancialFlowIdentity: () => ({ id: "test-flow", version: 1 }), - runFinancialOperation: async ({ attemptClass, perform }: { attemptClass: string; perform(key: string): Promise }) => { + runFinancialOperation: async ({ + attemptClass, + perform, + reconcile + }: { + attemptClass: string; + perform(key: string): Promise; + reconcile?: (operation: unknown) => Promise; + }) => { operationAttempts.push(attemptClass); - return perform(`test-${attemptClass}`); + if (operationRows.get(attemptClass) === "submitted") { + const reconciled = reconcile ? await reconcile({}) : null; + if (reconciled === null) throw new Error(`operation ${attemptClass} has submitted outcome`); + operationRows.set(attemptClass, "confirmed"); + return reconciled; + } + const result = await perform(`test-${attemptClass}`); + operationRows.set(attemptClass, "confirmed"); + return result; } })); @@ -431,6 +449,7 @@ describe("fixed Polygon Uniswap V3 execution failure branches", () => { async function runSwap(overrides: Partial>, now = Date.now()) { operationAttempts.length = 0; + operationRows.clear(); const { approval, simulated, state, swap } = await prepared(now); const signedApproval = await sign(approval); const signedSwap = await sign(swap); @@ -468,9 +487,8 @@ describe("fixed Polygon Uniswap V3 execution failure branches", () => { expect(operationAttempts).toEqual([]); }); - it("finishes a resumed swap whose transaction already settled instead of pausing", async () => { - // Crash after the swap was mined: the allowance is consumed and the EURe is gone, but the - // deterministic hash already has a successful receipt. + /** Crash after the swap was mined: allowance consumed, EURe gone, but the deterministic hash has a receipt. */ + function settledSwapDependencies() { const sendRawTransaction = mock(async (): Promise<`0x${string}`> => { throw new Error("must not resend a settled swap"); }); @@ -478,20 +496,41 @@ describe("fixed Polygon Uniswap V3 execution failure branches", () => { throw new Error("must not simulate a settled swap"); }); const getReceipt = mock(async (): Promise<{ status: "success" }> => ({ status: "success" })); - const error = await runSwap({ + return { getAllowance: async () => 0n, - getBalance: async token => (token === POLYGON_EURE ? 0n : 116_000_000n), + getBalance: async (token: `0x${string}`) => (token === POLYGON_EURE ? 0n : 116_000_000n), getReceipt, sendRawTransaction, simulateTransaction + }; + } + + it("finishes a resumed swap whose transaction already settled instead of pausing", async () => { + const dependencies = settledSwapDependencies(); + const error = await runSwap(dependencies); + expect(error).toBeNull(); + // One lookup decides the resume and also settles the journal: no second read that could fail + // transiently, no resend of the mined transaction. + expect(dependencies.getReceipt).toHaveBeenCalledTimes(1); + expect(dependencies.sendRawTransaction).not.toHaveBeenCalled(); + expect(dependencies.simulateTransaction).not.toHaveBeenCalled(); + expect(operationAttempts).toEqual(["uniswap-presigned-broadcast"]); + expect(operationRows.get("uniswap-presigned-broadcast")).toBe("confirmed"); + }); + + it("confirms a journal row left submitted by the crash from the receipt it already read", async () => { + const dependencies = settledSwapDependencies(); + // runSwap clears the rows, so seed the submitted row from the receipt read, which precedes the journal call. + const getReceipt = mock(async (): Promise<{ status: "success" }> => { + operationRows.set("uniswap-presigned-broadcast", "submitted"); + return { status: "success" }; }); + const error = await runSwap({ ...dependencies, getReceipt }); expect(error).toBeNull(); - // One lookup decides the resume; broadcast() is not entered, so a transient RPC failure on a - // second read cannot turn into a resend of the mined transaction. expect(getReceipt).toHaveBeenCalledTimes(1); - expect(sendRawTransaction).not.toHaveBeenCalled(); - expect(simulateTransaction).not.toHaveBeenCalled(); - expect(operationAttempts).toEqual([]); + expect(dependencies.sendRawTransaction).not.toHaveBeenCalled(); + expect(operationAttempts).toEqual(["uniswap-presigned-broadcast"]); + expect(operationRows.get("uniswap-presigned-broadcast")).toBe("confirmed"); }); it("retries later when the live quote moved below the soft minimum", async () => { diff --git a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts index 1ae1808e4..a510bb21c 100644 --- a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/execution.ts @@ -101,24 +101,32 @@ abstract class UniswapV3Executor extends BasePhaseHandler { return transaction; } + /** + * Sends the presigned transaction inside the financial-operation journal. `settledReceipt` is a + * successful receipt the caller already read for the transaction's deterministic hash: it lets a + * row left `submitted` by an earlier crash become `confirmed` without another RPC read or resend. + */ protected async broadcast( state: RampState, transaction: `0x${string}`, dependencies: UniswapV3ExecutionDependencies, - signal?: AbortSignal + signal?: AbortSignal, + settledReceipt?: Receipt ): Promise<`0x${string}`> { const deterministicHash = keccak256(transaction); - const existingReceipt = await dependencies.getReceipt(deterministicHash); - if (existingReceipt) { - if (existingReceipt.status !== "success") { + const settle = (receipt: Receipt | null): { hash: `0x${string}` } | null => { + if (!receipt) return null; + if (receipt.status !== "success") { throw new FinancialOperationRejectedError(`Uniswap transaction ${deterministicHash} reverted`); } - return deterministicHash; - } + return { hash: deterministicHash }; + }; const result = await this.runFinancialOperation(state, { attemptClass: "uniswap-presigned-broadcast", externalId: value => value.hash, perform: async () => { + const known = settledReceipt ? settle(settledReceipt) : null; + if (known) return known; throwIfAborted(signal); const hash = await dependencies.sendRawTransaction(transaction); if (hash.toLowerCase() !== deterministicHash.toLowerCase()) { @@ -129,14 +137,7 @@ abstract class UniswapV3Executor extends BasePhaseHandler { return { hash }; }, provider: Networks.Polygon, - reconcile: async () => { - const receipt = await dependencies.getReceipt(deterministicHash); - if (!receipt) return null; - if (receipt.status !== "success") { - throw new FinancialOperationRejectedError(`Uniswap transaction ${deterministicHash} reverted`); - } - return { hash: deterministicHash }; - }, + reconcile: async () => settle(settledReceipt ?? (await dependencies.getReceipt(deterministicHash))), request: { network: Networks.Polygon, signedTransaction: transaction }, signal }); @@ -181,10 +182,11 @@ export class UniswapSwapExecutor extends UniswapV3Executor { await validatePresignedEvmTransactionAgainstUnsigned(signed, unsigned); const transaction = await validateUniswapSwap(signed, expectation); // A swap that already settled on-chain has consumed the allowance, so the pre-flight checks can - // never pass again; skip them and the broadcast, which would otherwise re-read the receipt and, - // on a transient RPC failure, try to resend the mined transaction. - const settled = (await dependencies.getReceipt(keccak256(transaction)))?.status === "success"; - if (!settled) { + // never pass again. Skip them and hand the receipt to the journal, which then confirms its row + // without a second read that could fail transiently and trigger a resend. + const receipt = await dependencies.getReceipt(keccak256(transaction)); + const settledReceipt = receipt?.status === "success" ? receipt : undefined; + if (!settledReceipt) { if (BigInt(expectation.deadline) <= BigInt(Math.floor(Date.now() / 1000))) { throw this.createRecoverableError("Uniswap fixed swap signature expired before settlement"); } @@ -206,8 +208,8 @@ export class UniswapSwapExecutor extends UniswapV3Executor { throw this.createRecoverableError("Uniswap fixed swap quote moved below its soft minimum"); } await dependencies.simulateTransaction(transaction); - await this.broadcast(state, transaction, dependencies, signal); } + await this.broadcast(state, transaction, dependencies, signal, settledReceipt); const [remainingAllowance, outputBalance] = await Promise.all([ dependencies.getAllowance(expectation.signer), dependencies.getBalance(POLYGON_USDC, expectation.signer) diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index c2330b78e..ef0086259 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -102,7 +102,7 @@ recovery. 15. Production startup MUST fail without a Monerium auth-code client ID, exact callback URI, and explicit non-negative `MONERIUM_ISSUE_FEE_EUR`. The issue fee MUST NOT silently default to zero. The white-label credential pair is optional outside the B2B onramp; when it is absent, every Monerium read uses the user's OAuth token. Credentials MUST NOT be accepted from client requests. 16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's `monerium`/`eur` provider-customer binding for the requested `customerType` (`individual` or `business`); the widget supplies its individual type and the dashboard supplies the selected account type. With one bound profile, legacy type-less calls may use it; with more than one, they MUST fail with a public `409` `MONERIUM_CUSTOMER_TYPE_REQUIRED`. When white-label credentials are configured, the resolver MUST read the selected profile through the white-label app first and, only when that app answers `403` or `404`, through the user's backend-held OAuth token (`resolveMoneriumIdentity`); without them it MUST use the OAuth token directly. A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. Registration MUST hold transaction-scoped profile and owner advisory locks through the active-ramp check and ramp insert. It MUST reject registration with a public `409` while another Monerium ramp for the same owner is live (`findActiveMoneriumRampForOwner`: any non-terminal ramp except an unstarted one whose start window has closed), because permits for one owner share an ERC-2612 nonce and the mint executor attributes by balance delta. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. 17. Self-transfer registration MUST copy only owner, token, chain, and amount from trusted `monerium-issue` facts and MUST reject an owner that is also the EVM ephemeral. Its EURe permit and exact `transferFrom` MUST be independently validated and reconciled; strict presign completeness MUST require both the user-signed permit and ephemeral-signed transfer. A still-current permit MUST be consumed even when allowance already exists, while an advanced nonce or expired deadline may prove it non-replayable. Permit and transfer hashes MUST remain in namespaced block state, and successful execution MUST verify receipts and the exact allowance reduction. -18. The Polygon conversion MUST verify each pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, through the pinned packed path, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. When the swap's deterministic transaction hash already has a successful receipt, execution MUST skip the pre-broadcast deadline, deployment, balance, allowance, and quote checks and the broadcast itself (the mined swap has consumed the allowance, and a second receipt read that fails transiently must not turn into a resend) and proceed to that post-swap reconciliation, so a crash between settlement and phase completion resumes instead of pausing. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. +18. The Polygon conversion MUST verify each pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, through the pinned packed path, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. When the swap's deterministic transaction hash already has a successful receipt, execution MUST skip the pre-broadcast deadline, deployment, balance, allowance, and quote checks (the mined swap has consumed the allowance), MUST settle the financial-operation row from that same receipt so a `submitted` row becomes `confirmed` without a second read that could fail transiently or a resend, and then proceed to that post-swap reconciliation, so a crash between settlement and phase completion resumes instead of pausing. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. 19. Issue execution MUST wait for `currentOwnerBalance >= persistedBaseline + quotedPostFeeEureRaw`. Timeouts and exhausted RPC reads are recoverable. Missing or malformed settlement facts are unrecoverable corruption. The executor MUST transfer only the quoted post-fee amount; excess EURe remains in the owner wallet. This non-deterministic attribution exception is accepted only under RISK-023. 20. The owner permit carries the same deadline as the downstream swap presign (`config.swap.deadlineMinutes`, one week) from transaction preparation; its spender is the ramp's own ephemeral and its value exact, so the window bounds time only. An expired permit or consumed nonce MUST stop automatic self-transfer unless a sufficient safe allowance remains, and MUST pause the ramp for reconciliation (not a recoverable retry) when no allowance remains; the API MUST NOT fabricate or broaden authorization. The absence of automatic reauthorization/recovery is accepted under RISK-024. 21. `POST /v1/monerium/wallet` MUST verify the EOA signature over the fixed ownership message server-side before any provider call, MUST reject addresses with deployed code, MUST select the same legal profile type as the status read, MUST link through the app that can read that profile, MUST request its single IBAN only when it has none, and MUST hold the profile advisory lock through the list reads, the link, and the IBAN request so it serialises with IBAN moves and issue registration on that profile. `POST /v1/monerium/iban/move` MUST hold the same profile and current-owner advisory locks through the active-ramp check and provider mutation, and MUST reject with a public `409` while a live Monerium ramp still waits on the IBAN's current wallet, because the mint executor watches only the registered owner. Status reads (`GET /v1/monerium/status`, `GET /v1/onboarding/status`) MUST NOT mutate provider state; they report readiness for that legal type from the same list reads registration uses. OAuth-user API reads returning `401` at any point MUST surface `MONERIUM_REAUTHENTICATION_REQUIRED` so clients can reconnect.