From 4a43c01127cceaf5d4953af8a92c56da6e3232ce Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 12:20:16 +0200 Subject: [PATCH 1/6] fix(dashboard): keep a broadcast offramp retryable when update or start fails Once the wallet has broadcast a SELL's squidRouter transactions the funds sit on the ephemeral account, so a failed /ramp/update or /ramp/start (for example a maintenance-window 503) must not drop the ramp or the hashes. Signing now hands its output to a separate submit step, failures land in AwaitingRetry, and the recovery snapshot carries the unsubmitted output across reloads. --- .../dashboard/src/machines/transfer.actors.ts | 39 ++-- .../src/machines/transfer.machine.test.ts | 170 +++++++++++++++++- .../src/machines/transfer.machine.ts | 131 ++++++++++---- .../src/machines/transferActor.test.ts | 47 ++++- apps/dashboard/src/machines/transferActor.ts | 40 +++-- 5 files changed, 357 insertions(+), 70 deletions(-) diff --git a/apps/dashboard/src/machines/transfer.actors.ts b/apps/dashboard/src/machines/transfer.actors.ts index 697d7bb96..49f77f6af 100644 --- a/apps/dashboard/src/machines/transfer.actors.ts +++ b/apps/dashboard/src/machines/transfer.actors.ts @@ -19,7 +19,8 @@ import { type RampProcess, type RegisterRampRequest, signUnsignedTransactions, - type UnsignedTx + type UnsignedTx, + type UpdateRampRequest } from "@vortexfi/shared"; import { fetchQuote, type QuoteParams } from "@/services/api/quote.service"; import { shouldRefreshQuote } from "@/services/api/quote-expiry"; @@ -168,22 +169,23 @@ export async function registerTransfer(input: RegisterTransferInput): Promise; +} + /** * Ported from the widget's sign.actor (EVM paths): walks the user-owned transactions in * nonce order, signing typed data (offramp permits) and broadcasting squidRouter - * transactions with the connected wallet, then submits signatures + hashes via - * /ramp/update. + * transactions with the connected wallet. Returns the signatures + hashes for /ramp/update, + * which the machine submits as its own step so a failed update never re-broadcasts. */ -export async function signUserTransactions(input: SignUserTransactionsInput): Promise { - const { ramp, userTxs } = input; - if (userTxs.length === 0) { - return ramp; - } - +export async function signUserTransactions(input: SignUserTransactionsInput): Promise { + const { userTxs } = input; const sortedTxs = [...userTxs].sort((a, b) => a.nonce - b.nonce); const signedTxs: PresignedTx[] = []; let squidRouterApproveHash: string | undefined; @@ -220,13 +222,16 @@ export async function signUserTransactions(input: SignUserTransactionsInput): Pr throw error; } - return RampService.updateRamp(ramp.id, signedTxs, { - squidRouterApproveHash, - squidRouterNoPermitApproveHash, - squidRouterNoPermitSwapHash, - squidRouterNoPermitTransferHash, - squidRouterSwapHash - }); + return { + additionalData: { + squidRouterApproveHash, + squidRouterNoPermitApproveHash, + squidRouterNoPermitSwapHash, + squidRouterNoPermitTransferHash, + squidRouterSwapHash + }, + signedTxs + }; } const POLL_INTERVAL_MS = 3000; diff --git a/apps/dashboard/src/machines/transfer.machine.test.ts b/apps/dashboard/src/machines/transfer.machine.test.ts index 8080ff24e..40a618c7f 100644 --- a/apps/dashboard/src/machines/transfer.machine.test.ts +++ b/apps/dashboard/src/machines/transfer.machine.test.ts @@ -10,11 +10,14 @@ import { mock } from "bun:test"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createActor, fromPromise, waitFor } from "xstate"; -import type { TransferQuoteRequest } from "./transfer.actors"; +import type { TransferQuoteRequest, UserTxSubmission } from "./transfer.actors"; +const WALLET_TX_HASH = `0x${"cd".repeat(32)}`; +let broadcasts = 0; mock.module("@/services/transactions/userSigning", () => ({ - signAndSubmitEvmTransaction: () => { - throw new Error("Unexpected wallet signing in transfer machine test"); + signAndSubmitEvmTransaction: async () => { + broadcasts += 1; + return WALLET_TX_HASH; }, signMultipleTypedData: () => { throw new Error("Unexpected wallet signing in transfer machine test"); @@ -42,11 +45,12 @@ const ramp = { } as RampProcess; describe("transferMachine", () => { - it("blocks owner activation only during quote, balance, registration, and user signing", async () => { + it("blocks owner activation only during quote, balance, registration, user signing, and its submission", async () => { let releaseQuote: (() => void) | undefined; let releaseBalance: (() => void) | undefined; let releaseRegistration: (() => void) | undefined; let releaseSigning: (() => void) | undefined; + let releaseSubmission: (() => void) | undefined; let releaseStart: (() => void) | undefined; const sellQuote = { ...quote, rampType: RampDirection.SELL } as QuoteResponse; const sellRamp = { ...ramp, type: RampDirection.SELL } as RampProcess; @@ -65,9 +69,11 @@ describe("transferMachine", () => { ) ), signUserTransactions: fromPromise( - () => new Promise(resolve => (releaseSigning = () => resolve(sellRamp))) + () => + new Promise(resolve => (releaseSigning = () => resolve({ additionalData: {}, signedTxs: [] }))) ), startRamp: fromPromise(() => new Promise(resolve => (releaseStart = () => resolve(sellRamp)))), + submitUserTxs: fromPromise(() => new Promise(resolve => (releaseSubmission = () => resolve(sellRamp)))), trackRamp: fromPromise(async () => undefined) as never } }); @@ -99,7 +105,8 @@ describe("transferMachine", () => { ["CheckingQuote", () => releaseQuote?.()], ["CheckingBalance", () => releaseBalance?.()], ["Registering", () => releaseRegistration?.()], - ["SigningUserTxs", () => releaseSigning?.()] + ["SigningUserTxs", () => releaseSigning?.()], + ["SubmittingUserTxs", () => releaseSubmission?.()] ] as const) { await waitFor(actor, snapshot => snapshot.matches(state)); actor.send({ ownerProfileId: "profile-2", recovery: null, type: "ACTIVATE_OWNER" }); @@ -181,12 +188,16 @@ describe("transferMachine", () => { registerTransfer: fromPromise(async () => ({ ramp: eurRamp, userTxs: [permit] })), signUserTransactions: fromPromise(async ({ input }) => { signed += input.userTxs.length; - return { ...eurRamp, ibanPaymentData: { bic: "MONEEE00", iban: "EE52", receiverName: "Monerium" } } as RampProcess; + return { additionalData: {}, signedTxs: [] }; }), startRamp: fromPromise(async () => { startCalls += 1; return eurRamp; - }) + }), + submitUserTxs: fromPromise( + async () => + ({ ...eurRamp, ibanPaymentData: { bic: "MONEEE00", iban: "EE52", receiverName: "Monerium" } }) as RampProcess + ) } }); const actor = createActor(machine).start(); @@ -541,4 +552,147 @@ describe("transferMachine", () => { assert.equal(registerCalls, 0); actor.stop(); }); + + describe("offramp after the wallet broadcast", () => { + const wallet = "0x1111111111111111111111111111111111111111"; + const sellQuote = { id: "quote-sell", rampType: RampDirection.SELL } as QuoteResponse; + const sellRamp = { expiresAt: "2026-10-05T12:15:00.000Z", id: "ramp-sell", type: RampDirection.SELL } as RampProcess; + const walletTx = { + network: Networks.Polygon, + nonce: 0, + phase: "squidRouterNoPermitTransfer", + signer: wallet, + txData: { data: "0x", gas: "21000", to: "0x2222222222222222222222222222222222222222", value: "0" } + } as unknown as UnsignedTx; + const sellMeta = { + accountId: "account-1", + amountIn: "100", + amountInToken: "USDC", + corridorId: "MX" as const, + direction: RampDirection.SELL, + fiatPayoutAmount: "1850", + ownerProfileId: "profile-1", + payinNetwork: "polygon", + payoutCurrency: "MXN", + recipientEmail: "recipient@example.com", + recipientId: "recipient-1", + summary: "1850 MXN to recipient@example.com" + }; + const maintenance = "Vortex services are temporarily unavailable during scheduled maintenance"; + + function sellMachine(calls: { submissions: UserTxSubmission[]; starts: number }, fail: { submit?: number; start?: number }) { + return transferMachine.provide({ + actors: { + checkTransferBalance: fromPromise(async (): Promise => undefined), + refreshTransferQuote: fromPromise(async ({ input }) => ({ quote: input.quote })), + registerTransfer: fromPromise(async () => ({ ramp: sellRamp, userTxs: [walletTx] })), + startRamp: fromPromise(async () => { + calls.starts += 1; + if (calls.starts <= (fail.start ?? 0)) throw new Error(maintenance); + return sellRamp; + }), + submitUserTxs: fromPromise(async ({ input }) => { + calls.submissions.push(input.submission); + if (calls.submissions.length <= (fail.submit ?? 0)) throw new Error(maintenance); + return sellRamp; + }), + trackRamp: fromPromise(async () => undefined) as never + } + }); + } + + function startSell(actor: { send: (event: never) => void }) { + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" } as never); + actor.send({ + additionalData: { walletAddress: wallet }, + meta: sellMeta, + ownerProfileId: "profile-1", + quote: sellQuote, + quoteRequest: { ...quoteRequest, params: { ...quoteRequest.params, direction: RampDirection.SELL } }, + type: "START" + } as never); + } + + it("keeps the ramp and broadcast hash when the final update fails, and resends it without re-broadcasting", async () => { + broadcasts = 0; + const calls = { starts: 0, submissions: [] as UserTxSubmission[] }; + const actor = createActor(sellMachine(calls, { submit: 1 })).start(); + startSell(actor); + + await waitFor(actor, snapshot => snapshot.matches("AwaitingRetry")); + const failed = actor.getSnapshot().context; + assert.equal(broadcasts, 1); + assert.equal(calls.starts, 0); + assert.equal(failed.errorMessage, maintenance); + assert.equal(failed.ramp?.id, "ramp-sell"); + assert.equal(failed.ramp?.expiresAt, "2026-10-05T12:15:00.000Z"); + assert.equal(failed.userTxSubmission?.additionalData.squidRouterNoPermitTransferHash, WALLET_TX_HASH); + + actor.send({ ownerProfileId: "profile-2", type: "RETRY" }); + assert.equal(actor.getSnapshot().value, "AwaitingRetry"); + + actor.send({ ownerProfileId: "profile-1", type: "RETRY" }); + await waitFor(actor, snapshot => snapshot.matches("Tracking")); + assert.equal(broadcasts, 1); + assert.equal(calls.submissions.length, 2); + assert.deepEqual(calls.submissions[1], calls.submissions[0]); + assert.equal(calls.starts, 1); + assert.equal(actor.getSnapshot().context.userTxSubmission, null); + assert.equal(actor.getSnapshot().context.errorMessage, null); + actor.stop(); + }); + + it("retries only the start once the update went through", async () => { + broadcasts = 0; + const calls = { starts: 0, submissions: [] as UserTxSubmission[] }; + const actor = createActor(sellMachine(calls, { start: 1 })).start(); + startSell(actor); + + await waitFor(actor, snapshot => snapshot.matches("AwaitingRetry")); + assert.equal(actor.getSnapshot().context.errorMessage, maintenance); + assert.equal(actor.getSnapshot().context.userTxSubmission, null); + assert.equal(actor.getSnapshot().context.ramp?.id, "ramp-sell"); + + actor.send({ ownerProfileId: "profile-1", type: "RETRY" }); + await waitFor(actor, snapshot => snapshot.matches("Tracking")); + assert.equal(broadcasts, 1); + assert.equal(calls.submissions.length, 1); + assert.equal(calls.starts, 2); + actor.stop(); + }); + + it("resumes a restored offramp at the update it still owes, without the wallet", async () => { + broadcasts = 0; + const calls = { starts: 0, submissions: [] as UserTxSubmission[] }; + const submission: UserTxSubmission = { + additionalData: { squidRouterNoPermitTransferHash: WALLET_TX_HASH }, + signedTxs: [] + }; + const actor = createActor(sellMachine(calls, {})).start(); + actor.send({ + ownerProfileId: "profile-1", + recovery: { + activeOwnerProfileId: "profile-1", + additionalData: null, + errorMessage: null, + lastStatus: null, + meta: sellMeta, + quote: sellQuote, + quoteRequest: null, + ramp: sellRamp, + userTxSubmission: submission, + userTxs: [] + }, + type: "ACTIVATE_OWNER" + }); + assert.equal(actor.getSnapshot().value, "AwaitingRetry"); + + actor.send({ ownerProfileId: "profile-1", type: "RETRY" }); + await waitFor(actor, snapshot => snapshot.matches("Tracking")); + assert.equal(broadcasts, 0); + assert.deepEqual(calls.submissions, [submission]); + assert.equal(calls.starts, 1); + actor.stop(); + }); + }); }); diff --git a/apps/dashboard/src/machines/transfer.machine.ts b/apps/dashboard/src/machines/transfer.machine.ts index 0648e46af..f6ac1d97e 100644 --- a/apps/dashboard/src/machines/transfer.machine.ts +++ b/apps/dashboard/src/machines/transfer.machine.ts @@ -5,7 +5,7 @@ import { type RampProcess, type UnsignedTx } from "@vortexfi/shared"; -import { assign, emit, fromCallback, fromPromise, setup } from "xstate"; +import { and, assign, emit, fromCallback, fromPromise, setup } from "xstate"; import type { Transaction } from "@/domain/types"; import { type CheckTransferBalanceInput, @@ -18,7 +18,8 @@ import { registerTransfer, signUserTransactions, type TransferQuoteRequest, - UserRejectedError + UserRejectedError, + type UserTxSubmission } from "./transfer.actors"; /** Everything the transactions table needs, captured at submit time. */ @@ -37,6 +38,8 @@ export interface TransferContext { meta: TransferMeta | null; ramp: RampProcess | null; userTxs: UnsignedTx[]; + /** Wallet output not yet accepted by /ramp/update. Kept so a retry resends it instead of re-signing. */ + userTxSubmission: UserTxSubmission | null; lastStatus: GetRampStatusResponse | null; errorMessage: string | null; } @@ -53,6 +56,7 @@ export type TransferEvent = | { type: "STATUS_UPDATE"; status: GetRampStatusResponse } | { type: "TERMINAL"; status: GetRampStatusResponse } | { type: "PAYMENT_CONFIRMED"; ownerProfileId: string } + | { type: "RETRY"; ownerProfileId: string } | { type: "ACTIVATE_OWNER"; ownerProfileId: string; recovery: TransferContext | null } | { type: "RESET" }; @@ -70,6 +74,7 @@ const initialContext: TransferContext = { quote: null, quoteRequest: null, ramp: null, + userTxSubmission: null, userTxs: [] }; @@ -107,13 +112,15 @@ export const transferMachine = setup({ checkTransferBalance: fromPromise(({ input }: { input: CheckTransferBalanceInput }) => checkTransferBalance(input)), refreshTransferQuote: fromPromise(({ input }: { input: RefreshTransferQuoteInput }) => refreshTransferQuote(input)), registerTransfer: fromPromise(({ input }: { input: RegisterTransferInput }) => registerTransfer(input)), - signUserTransactions: fromPromise(({ input }: { input: { ramp: RampProcess; userTxs: UnsignedTx[] } }) => - signUserTransactions(input) - ), + signUserTransactions: fromPromise(({ input }: { input: { userTxs: UnsignedTx[] } }) => signUserTransactions(input)), startRamp: fromPromise(async ({ input }: { input: { rampId: string } }) => { const { RampService } = await import("@/services/api/ramp.service"); return RampService.startRamp(input.rampId); }), + submitUserTxs: fromPromise(async ({ input }: { input: { rampId: string; submission: UserTxSubmission } }) => { + const { RampService } = await import("@/services/api/ramp.service"); + return RampService.updateRamp(input.rampId, input.submission.signedTxs, input.submission.additionalData); + }), trackRamp: fromCallback(({ sendBack, input }) => pollRampUntilTerminal( input.rampId, @@ -123,6 +130,8 @@ export const transferMachine = setup({ ) }, guards: { + hasPendingUserTxs: ({ context }) => context.userTxSubmission !== null, + isOfframpRecovery: ({ event }) => event.type === "ACTIVATE_OWNER" && event.recovery?.quote?.rampType === RampDirection.SELL, isOnramp: ({ context }) => context.quote?.rampType === RampDirection.BUY, isOnrampWithoutUserTxs: ({ context, event }) => { const output = (event as unknown as { output?: RegisterTransferOutput }).output; @@ -146,6 +155,11 @@ export const transferMachine = setup({ initial: "Idle", on: { ACTIVATE_OWNER: [ + { + actions: assign(({ event }) => ({ ...event.recovery, activeOwnerProfileId: event.ownerProfileId })), + guard: "isOfframpRecovery", + target: ".AwaitingRetry" + }, { actions: assign(({ event }) => ({ ...event.recovery, activeOwnerProfileId: event.ownerProfileId })), guard: "isRecoveryActivation", @@ -171,6 +185,25 @@ export const transferMachine = setup({ } } }, + // An offramp whose wallet transactions are already broadcast, but whose /ramp/update or + // /ramp/start failed. The funds sit on the ephemeral account, so the ramp and the wallet + // output must survive: RETRY resends the failed call, never the wallet transactions. + AwaitingRetry: { + on: { + RETRY: [ + { + actions: assign(() => ({ errorMessage: null })), + guard: and(["isOwnerEvent", "hasPendingUserTxs"]), + target: "SubmittingUserTxs" + }, + { + actions: assign(() => ({ errorMessage: null })), + guard: "isOwnerEvent", + target: "Starting" + } + ] + } + }, CheckingBalance: { invoke: { input: ({ context }) => { @@ -297,32 +330,11 @@ export const transferMachine = setup({ }, SigningUserTxs: { invoke: { - input: ({ context }) => { - if (!context.ramp) { - throw new Error("Ramp is missing"); - } - return { ramp: context.ramp, userTxs: context.userTxs }; + input: ({ context }) => ({ userTxs: context.userTxs }), + onDone: { + actions: assign(({ event }) => ({ userTxSubmission: event.output })), + target: "SubmittingUserTxs" }, - onDone: [ - { - // An onramp releases its payment instructions only once the owner-signed - // transactions are in, so keep whatever the update returned. - actions: assign(({ context, event }) => ({ - ramp: { - ...event.output, - achPaymentData: event.output.achPaymentData ?? context.ramp?.achPaymentData, - depositQrCode: event.output.depositQrCode ?? context.ramp?.depositQrCode, - ibanPaymentData: event.output.ibanPaymentData ?? context.ramp?.ibanPaymentData - } - })), - guard: "isOnramp", - target: "AwaitingPayment" - }, - { - actions: assign(({ event }) => ({ ramp: event.output })), - target: "Starting" - } - ], onError: { actions: [ assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), @@ -353,8 +365,9 @@ export const transferMachine = setup({ })), target: "Tracking" }, - // A BUY user may already have paid, so the ramp and its instructions must survive a - // failed start: back to AwaitingPayment, where PAYMENT_CONFIRMED retries the same ramp. + // A BUY user may already have paid, and a SELL user's tokens have already left the wallet, + // so the ramp must survive a failed start: back to AwaitingPayment (PAYMENT_CONFIRMED) or + // AwaitingRetry (RETRY), both of which retry the same ramp. onError: [ { actions: [ @@ -369,12 +382,64 @@ export const transferMachine = setup({ assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), emit(({ event }) => ({ message: errorMessage(event.error), type: "TRANSFER_FAILED" as const })) ], - target: "Failed" + target: "AwaitingRetry" } ], src: "startRamp" } }, + SubmittingUserTxs: { + invoke: { + input: ({ context }) => { + if (!context.ramp || !context.userTxSubmission) { + throw new Error("Signed wallet transactions are missing"); + } + return { rampId: context.ramp.id, submission: context.userTxSubmission }; + }, + onDone: [ + { + // An onramp releases its payment instructions only once the owner-signed + // transactions are in, so keep whatever the update returned. + actions: assign(({ context, event }) => ({ + ramp: { + ...event.output, + achPaymentData: event.output.achPaymentData ?? context.ramp?.achPaymentData, + depositQrCode: event.output.depositQrCode ?? context.ramp?.depositQrCode, + ibanPaymentData: event.output.ibanPaymentData ?? context.ramp?.ibanPaymentData + }, + userTxSubmission: null + })), + guard: "isOnramp", + target: "AwaitingPayment" + }, + { + actions: assign(({ event }) => ({ ramp: event.output, userTxSubmission: null })), + target: "Starting" + } + ], + // An onramp's wallet only signed a permit, so nothing has moved yet. An offramp's + // wallet has already broadcast: keep everything so the same update can be resent. + onError: [ + { + actions: [ + assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), + emit(({ event }) => ({ message: errorMessage(event.error), type: "TRANSFER_FAILED" as const })) + ], + guard: "isOnramp", + target: "Failed" + }, + { + actions: [ + assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), + emit(({ event }) => ({ message: errorMessage(event.error), type: "TRANSFER_FAILED" as const })) + ], + target: "AwaitingRetry" + } + ], + src: "submitUserTxs" + }, + on: { ACTIVATE_OWNER: {} } + }, Tracking: { entry: emit(({ context }) => { if (!context.ramp || !context.meta) { diff --git a/apps/dashboard/src/machines/transferActor.test.ts b/apps/dashboard/src/machines/transferActor.test.ts index a23577e88..f7c2a9767 100644 --- a/apps/dashboard/src/machines/transferActor.test.ts +++ b/apps/dashboard/src/machines/transferActor.test.ts @@ -137,7 +137,9 @@ describe("transferActor owner recovery", () => { const corruptions = [ { ...complete, version: undefined }, { ...complete, ramp: undefined }, - { ...complete, meta: { ...complete.meta, ownerProfileId: "profile-else" } } + { ...complete, meta: { ...complete.meta, ownerProfileId: "profile-else" } }, + // Only an offramp owes /ramp/update after its wallet step; a BUY snapshot never carries one. + { ...complete, userTxSubmission: { additionalData: {}, signedTxs: [] } } ]; for (const corruption of corruptions) { @@ -171,6 +173,49 @@ describe("transferActor owner recovery", () => { assert.equal(transferActor.getSnapshot().context.activeOwnerProfileId, "profile-5"); }); + it("restores an offramp awaiting retry with the wallet output it still owes the API", () => { + const key = "vortex-dashboard-transfer-state:owner:profile-6"; + const hash = `0x${"cd".repeat(32)}`; + const snapshot = { + meta: { + accountId: "account-6", + amountIn: "100", + amountInToken: "USDC", + corridorId: "MX", + direction: RampDirection.SELL, + fiatPayoutAmount: "1850", + ownerProfileId: "profile-6", + payinNetwork: "polygon", + payoutCurrency: "MXN", + recipientEmail: "recipient@example.com", + recipientId: "recipient-6", + summary: "1850 MXN to recipient@example.com" + }, + ownerProfileId: "profile-6", + quote: { id: "quote-sell", rampType: RampDirection.SELL }, + ramp: { expiresAt: "2026-10-05T12:15:00.000Z", id: "ramp-sell", type: RampDirection.SELL }, + userTxSubmission: { additionalData: { squidRouterNoPermitTransferHash: hash }, signedTxs: [] }, + version: 1 + }; + + values.set(key, JSON.stringify({ ...snapshot, userTxSubmission: { additionalData: {}, signedTxs: "corrupt" } })); + assert.equal(activateTransferOwner("profile-6"), true); + assert.equal(values.has(key), false); + assert.equal(transferActor.getSnapshot().value, "Idle"); + + assert.equal(activateTransferOwner("profile-existing"), true); + values.set(key, JSON.stringify(snapshot)); + assert.equal(activateTransferOwner("profile-6"), true); + const restored = transferActor.getSnapshot(); + assert.equal(restored.value, "AwaitingRetry"); + assert.equal(restored.context.ramp?.id, "ramp-sell"); + assert.equal(restored.context.userTxSubmission?.additionalData.squidRouterNoPermitTransferHash, hash); + assert.deepEqual(JSON.parse(values.get(key) ?? "null"), snapshot); + + resetTransferState(); + assert.equal(values.has(key), false); + }); + it("allows identity changes while idle", () => { assert.equal(canChangeEffectiveIdentity(), true); }); diff --git a/apps/dashboard/src/machines/transferActor.ts b/apps/dashboard/src/machines/transferActor.ts index 5691d4506..33b29c24c 100644 --- a/apps/dashboard/src/machines/transferActor.ts +++ b/apps/dashboard/src/machines/transferActor.ts @@ -3,6 +3,7 @@ import { createActor } from "xstate"; import { TRANSACTIONS_QUERY_KEY } from "@/hooks/useTransactions"; import { notifyTransferCompleted } from "@/lib/notify"; import { queryClient } from "@/lib/queryClient"; +import type { UserTxSubmission } from "./transfer.actors"; import { type TransferContext, type TransferMeta, transferMachine } from "./transfer.machine"; /** @@ -18,6 +19,8 @@ interface PersistedTransferRecovery { ownerProfileId: string; quote: QuoteResponse; ramp: RampProcess; + /** Offramp only: wallet output still owed to /ramp/update. Absent in BUY snapshots. */ + userTxSubmission?: UserTxSubmission | null; version: typeof TRANSFER_RECOVERY_VERSION; } @@ -33,14 +36,20 @@ function recoveryContext(value: Record, ownerProfileId: string) const quote = value.quote; const meta = value.meta; const ramp = value.ramp; - return isRecord(quote) && - quote.rampType === RampDirection.BUY && + const submission = value.userTxSubmission ?? null; + const direction = isRecord(quote) ? quote.rampType : undefined; + return (direction === RampDirection.BUY || direction === RampDirection.SELL) && isRecord(meta) && meta.ownerProfileId === ownerProfileId && - meta.direction === RampDirection.BUY && + meta.direction === direction && isRecord(ramp) && - ramp.type === RampDirection.BUY && - typeof ramp.id === "string" + ramp.type === direction && + typeof ramp.id === "string" && + (submission === null || + (direction === RampDirection.SELL && + isRecord(submission) && + Array.isArray(submission.signedTxs) && + isRecord(submission.additionalData))) ? { activeOwnerProfileId: ownerProfileId, additionalData: null, @@ -50,6 +59,7 @@ function recoveryContext(value: Record, ownerProfileId: string) quote: quote as unknown as QuoteResponse, quoteRequest: null, ramp: ramp as unknown as RampProcess, + userTxSubmission: submission as UserTxSubmission | null, userTxs: [] } : undefined; @@ -87,7 +97,8 @@ export function canChangeEffectiveIdentity(): boolean { snapshot.matches("CheckingQuote") || snapshot.matches("CheckingBalance") || snapshot.matches("Registering") || - snapshot.matches("SigningUserTxs") + snapshot.matches("SigningUserTxs") || + snapshot.matches("SubmittingUserTxs") ); } @@ -148,9 +159,17 @@ transferActor.on("STATUS_CHANGED", event => { transferActor.subscribe(snapshot => { try { - if (snapshot.matches("AwaitingPayment")) { + // A BUY user may already have paid, and a SELL user's wallet has already broadcast, so from + // here until tracking a reload must bring the ramp back (and an offramp's unsubmitted wallet + // output) so update/start can be retried. + if ( + snapshot.matches("AwaitingPayment") || + snapshot.matches("SubmittingUserTxs") || + snapshot.matches("Starting") || + snapshot.matches("AwaitingRetry") + ) { const ownerProfileId = snapshot.context.activeOwnerProfileId; - const { meta, quote, ramp } = snapshot.context; + const { meta, quote, ramp, userTxSubmission } = snapshot.context; if (!ownerProfileId || meta?.ownerProfileId !== ownerProfileId || !quote || !ramp) { return; } @@ -159,13 +178,12 @@ transferActor.subscribe(snapshot => { ownerProfileId, quote, ramp, + userTxSubmission, version: TRANSFER_RECOVERY_VERSION }; localStorage.setItem(storageKey(ownerProfileId), JSON.stringify(recovery)); refreshTransactions(); - } else if (!snapshot.matches("Starting")) { - // Keep the AwaitingPayment snapshot through Starting: the user may already have - // paid, and a reload must bring the instructions back so start can be retried. + } else { const ownerProfileId = snapshot.context.activeOwnerProfileId; if (ownerProfileId) { localStorage.removeItem(storageKey(ownerProfileId)); From 2c2143d29742741e6f08a30a066e155b92477736 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 12:20:17 +0200 Subject: [PATCH 2/6] feat(dashboard): let senders retry a stalled offramp before its start deadline The API refuses to start a ramp 15 minutes after registration, so the retry panel counts down to expiresAt and, once it passes, gives the ramp ID for a support-led recovery instead of a retry that can only fail. --- .../src/components/transfer/OnrampForm.tsx | 4 +- .../src/components/transfer/TransferForm.tsx | 121 +++++++++++++++++- .../src/routes/_app/transactions.tsx | 23 ++-- 3 files changed, 135 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/components/transfer/OnrampForm.tsx b/apps/dashboard/src/components/transfer/OnrampForm.tsx index 793782e69..d9e2df6f7 100644 --- a/apps/dashboard/src/components/transfer/OnrampForm.tsx +++ b/apps/dashboard/src/components/transfer/OnrampForm.tsx @@ -123,8 +123,10 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi transferState.matches("CheckingBalance") || transferState.matches("Registering") || transferState.matches("SigningUserTxs") || + transferState.matches("SubmittingUserTxs") || transferState.matches("AwaitingPayment") || transferState.matches("Starting") || + transferState.matches("AwaitingRetry") || transferState.matches("Tracking"); if (transferState.matches("AwaitingPayment") && transferState.context.ramp && belongsToActiveOwner) { @@ -343,7 +345,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi + + ); + } + + return ( +
+
+ +
+

Your transfer hasn’t started yet

+

{startError ?? "The page was reloaded before the transfer started."}

+

Your tokens already left your wallet. Trying again finishes starting this transfer and never sends them again.

+
+
+ {Number.isFinite(deadline) && ( +

+ Try again within {Math.ceil((deadline - now) / 60_000)} min (before{" "} + {new Date(deadline).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}). After that the transfer can no + longer be started. +

+ )} + +
+ ); +} + function Row({ label, children }: { label: string; children: React.ReactNode }) { return (
diff --git a/apps/dashboard/src/routes/_app/transactions.tsx b/apps/dashboard/src/routes/_app/transactions.tsx index 7ac7433ae..16ce7ef60 100644 --- a/apps/dashboard/src/routes/_app/transactions.tsx +++ b/apps/dashboard/src/routes/_app/transactions.tsx @@ -20,11 +20,14 @@ function TransactionsPage() { const account = useActiveAccount(); const { transactions } = useTransactions(account); const { recipients } = useRecipients(account); - const resumableRamp = useSelector(transferActor, snapshot => - snapshot.matches("AwaitingPayment") && + const resumable = useSelector(transferActor, snapshot => snapshot.context.meta?.ownerProfileId === snapshot.context.activeOwnerProfileId && snapshot.context.meta.accountId === account?.id - ? snapshot.context.ramp + ? snapshot.matches("AwaitingPayment") + ? "payment" + : snapshot.matches("AwaitingRetry") + ? "start" + : null : null ); @@ -42,19 +45,23 @@ function TransactionsPage() {

Pay-in and pay-out history for {account.name}.

- {resumableRamp && ( + {resumable && (
-

Payment awaiting confirmation

+

+ {resumable === "payment" ? "Payment awaiting confirmation" : "Transfer not started yet"} +

- Your payment instructions are saved until the payment window expires. + {resumable === "payment" + ? "Your payment instructions are saved until the payment window expires." + : "Your tokens left your wallet. Start the transfer before its start window closes."}

From 77541475c9287c66226e32232bd5d36dab01644d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 12:20:18 +0200 Subject: [PATCH 3/6] test(dashboard): cover a post-broadcast offramp retry across a reload --- apps/dashboard/e2e/support/mockBackend.ts | 11 +++++ apps/dashboard/e2e/support/mockWallet.ts | 6 ++- .../e2e/transfer-mxn-journey.spec.ts | 43 ++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index 21280da49..932fb45f5 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -299,6 +299,9 @@ interface MockBackendOptions { rampRegisterError?: string; // Fail this many POST /v1/ramp/start calls with a 500 before succeeding. rampStartFailures?: number; + // Fail this many POST /v1/ramp/update calls that report wallet hashes (the offramp's final + // update) with the maintenance guard's 503 before succeeding. + rampHashUpdateFailures?: number; onrampCurrency?: "ARS" | "BRL" | "COP" | "MXN" | "USD"; quoteOverrides?: (requestIndex: number, requestBody: Record) => Record; tokenBalances?: TokenBalances | null | ((requestIndex: number, network: BalanceNetwork) => TokenBalances | null); @@ -1000,6 +1003,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) } if (path === "/v1/ramp/update" && method === "POST") { updateRequests.push(request.postDataJSON() as Record); + const hashUpdates = updateRequests.filter(body => body.additionalData).length; + if (updateRequests.at(-1)?.additionalData && hashUpdates <= (options.rampHashUpdateFailures ?? 0)) { + await fulfillJson( + { message: "Vortex services are temporarily unavailable during scheduled maintenance: Upgrade - Back soon." }, + 503 + ); + return; + } const isOnramp = quoteRequests.at(-1)?.rampType === "BUY"; const paymentData = isOnramp ? options.onrampCurrency === "BRL" diff --git a/apps/dashboard/e2e/support/mockWallet.ts b/apps/dashboard/e2e/support/mockWallet.ts index 7006365e9..c31dff157 100644 --- a/apps/dashboard/e2e/support/mockWallet.ts +++ b/apps/dashboard/e2e/support/mockWallet.ts @@ -4,6 +4,8 @@ export const MOCK_WALLET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; export const MOCK_WALLET_NAME = "E2E Mock Wallet"; /** The hash the stub returns for every eth_sendTransaction. */ export const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`; +/** sessionStorage key counting eth_sendTransaction calls in the tab; it survives reloads. */ +export const MOCK_WALLET_SEND_COUNT_KEY = "e2e-mock-wallet-sends"; // Copied from apps/frontend/e2e/support/mockWallet.ts. Injects a minimal EIP-1193 provider // announced via EIP-6963 before the app loads. wagmi discovers announced providers by default @@ -14,7 +16,7 @@ export const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`; // a mid-flow chain switch in signAndSubmitEvmTransaction. export async function injectMockWallet(page: Page, options: { chainIdHex?: string } = {}) { await page.addInitScript( - ({ address, name, chainIdHex, txHash }) => { + ({ address, name, chainIdHex, sendCountKey, txHash }) => { // biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub const listeners: Record void>> = {}; const provider = { @@ -46,6 +48,7 @@ export async function injectMockWallet(page: Page, options: { chainIdHex?: strin return `0x${"ab".repeat(65)}`; // The offramp's user-owned squidRouterNoPermitTransfer is broadcast through here. case "eth_sendTransaction": + sessionStorage.setItem(sendCountKey, String(Number(sessionStorage.getItem(sendCountKey) ?? 0) + 1)); return txHash; case "eth_getTransactionReceipt": return { @@ -89,6 +92,7 @@ export async function injectMockWallet(page: Page, options: { chainIdHex?: strin address: MOCK_WALLET_ADDRESS, chainIdHex: options.chainIdHex ?? "0x2105", name: MOCK_WALLET_NAME, + sendCountKey: MOCK_WALLET_SEND_COUNT_KEY, txHash: MOCK_WALLET_TX_HASH } ); diff --git a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts index b49721f9f..0f67e444a 100644 --- a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts +++ b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; import { E2E_FIAT_ACCOUNT_ID, E2E_FIAT_ACCOUNT_ID_2, E2E_QUOTE_ID, E2E_RAMP_ID, mockBackend } from "./support/mockBackend"; -import { injectMockWallet, MOCK_WALLET_ADDRESS, MOCK_WALLET_TX_HASH } from "./support/mockWallet"; +import { injectMockWallet, MOCK_WALLET_ADDRESS, MOCK_WALLET_SEND_COUNT_KEY, MOCK_WALLET_TX_HASH } from "./support/mockWallet"; import { seedSession } from "./support/session"; const EXPECTED_PAYIN_USDC = "54.054054"; @@ -124,6 +124,47 @@ test("SELL MXN transfer: quote, register, ephemeral presigning, wallet broadcast expect(backend.unexpectedExternalRequests).toEqual([]); }); +// Once the wallet has broadcast, the tokens sit on the ramp's ephemeral account. A failed final +// update (here the maintenance guard's 503) must keep the ramp and its hash, survive a reload, and +// resend that same update on retry, never the wallet transaction. +test("SELL retries a failed post-broadcast update after a reload without re-broadcasting", async ({ page }) => { + const backend = await mockBackend(page, { rampHashUpdateFailures: 1 }); + await injectMockWallet(page, { chainIdHex: "0x89" }); + await seedSession(page); + await page.goto("/transfer?network=polygon"); + + const amountInput = page.locator("#token-amount"); + await expect(amountInput).toBeVisible({ timeout: 20_000 }); + await amountInput.fill(EXPECTED_PAYIN_USDC); + const sendButton = page.getByRole("button", { name: /Send/ }); + await expect(sendButton).toBeEnabled({ timeout: 20_000 }); + await sendButton.click(); + + const notStarted = page.getByRole("alert").filter({ hasText: "Your transfer hasn’t started yet" }); + await expect(notStarted).toBeVisible({ timeout: 30_000 }); + await expect(notStarted).toContainText("scheduled maintenance"); + await expect(page.getByText(/Try again within \d+ min/)).toBeVisible(); + expect(backend.updateRequests).toHaveLength(2); + expect(backend.startRequests).toHaveLength(0); + + await page.reload(); + await expect(page.getByText("The page was reloaded before the transfer started.")).toBeVisible({ timeout: 20_000 }); + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page.getByText("Transfer initiated")).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/transactions/); + + expect(backend.registerRequests).toHaveLength(1); + expect(backend.updateRequests).toHaveLength(3); + expect(backend.updateRequests[2]).toEqual(backend.updateRequests[1]); + expect((backend.updateRequests[2] as { additionalData?: Record }).additionalData).toEqual({ + squidRouterNoPermitTransferHash: MOCK_WALLET_TX_HASH + }); + expect(backend.startRequests).toHaveLength(1); + expect(await page.evaluate(key => sessionStorage.getItem(key), MOCK_WALLET_SEND_COUNT_KEY)).toBe("1"); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + test("SELL refreshes a near-expiry quote before registration", async ({ page }) => { const backend = await mockBackend(page, { quoteOverrides: requestIndex => { From fb09b6c492b3a19c715c6ddc8e1c903793138008 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 12:20:18 +0200 Subject: [PATCH 4/6] docs(dashboard): document the offramp retry state and its recovery snapshot --- docs/operations-testing.md | 4 ++- docs/product-dashboard.md | 28 +++++++++++++------ .../02-signing-keys/ephemeral-accounts.md | 2 +- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 104d726b7..47425104e 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -198,7 +198,9 @@ different set of endpoints than the widget. Covered so far: Alfredpay fiat account → quote → registration with fresh ephemeral keypairs → in-page ephemeral signing asserted via the raw EIP-1559 txs posted to `/ramp/update` → USER-WALLET broadcast of the `squidRouterNoPermitTransfer` with its hash reported in a second update → `/ramp/start` → status - polling to a terminal phase while the form navigates to `/transactions`. A second test pins + polling to a terminal phase while the form navigates to `/transactions`. A retry test fails that + second update with the maintenance 503, reloads, and asserts **Try again** resends the identical + update then starts, with exactly one wallet broadcast across the reload. Another test pins payout-account selection: the mock serves two saved fiat accounts, and choosing the non-default one must register against *that* `fiatAccountId` — a broken selector would pay the wrong account. - **Onramps and transfer modes** (`onramp-journeys.spec.ts`): route-backed Offramp/Onramp/ diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 5fb2bdff2..153680af1 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -258,7 +258,7 @@ provider-shaped rather than UI-shaped. - **Reuse the ramp core.** `transfer.machine.ts` carries two direction-specific paths: SELL runs quote freshness check → source-wallet balance check → register → presign ephemeral → user wallet - signature → start → poll; BUY + signature/broadcast → `/ramp/update` with the signatures and hashes → start → poll; BUY runs quote freshness check → register → presign ephemeral → `AwaitingPayment` → explicit payment confirmation → start → poll. The dashboard schedules form quote refreshes from `createdAt` and `expiresAt` when 60% of validity remains. The machine repeats that check before registration; if @@ -273,12 +273,21 @@ provider-shaped rather than UI-shaped. `@vortexfi/shared`. `RampService` is loaded via a dynamic import inside the transfer machine, but there is no route-level code splitting yet — the Polkadot/EVM graph is statically reachable from the entry chunk, so non-transfer pages do not - currently avoid it. The machine snapshot is persisted under a dashboard-specific key — only in - the recoverable `AwaitingPayment` state, never mid-request — so provider payment instructions - survive navigation and reload. A failed BUY start returns to `AwaitingPayment` with the same - ramp, so a user who already sent fiat keeps the instructions and can retry start. Registered - instructions remain visible until their 15-minute start window closes; once expired, the - dashboard hides the stale details and allows resetting for a new quote. + currently avoid it. The machine snapshot is persisted under a dashboard-specific key in the + recoverable states — BUY `AwaitingPayment`, and a SELL from its wallet broadcast until tracking + (`SubmittingUserTxs`, `Starting`, `AwaitingRetry`) — so provider payment instructions and + already-broadcast offramps survive navigation and reload. A failed BUY start returns to + `AwaitingPayment` with the same ramp, so a user who already sent fiat keeps the instructions and + can retry start. Registered instructions remain visible until their 15-minute start window + closes; once expired, the dashboard hides the stale details and allows resetting for a new quote. + A SELL whose post-broadcast `/ramp/update` or `/ramp/start` fails (a maintenance-window 503, a + network error) moves to `AwaitingRetry` with the ramp and any unsubmitted signatures and hashes; + **Try again** resends only the failed call and never re-signs or re-broadcasts. The pay-out form + shows the ramp's 15-minute start deadline (`expiresAt`); once it passes, the API refuses the + start, so the dashboard shows the ramp ID for a support-led recovery from the local ephemeral + archive and allows starting a new transfer. The transactions page offers **Resume transfer** for + that state. A partially broadcast wallet sequence (one transaction sent, a later one rejected) + still ends in `Failed`. - **Preserve ramp recovery keys.** The dashboard stores each ramp's EVM and Substrate ephemeral secrets locally before registration (under a @@ -291,8 +300,9 @@ provider-shaped rather than UI-shaped. to the account that created the ramp; switching accounts does not expose its payment details. Managed-child selection extends this rule by keying resumable snapshots to the effective owner profile rather than using one global snapshot. Selection changes are forbidden while the machine - is in `CheckingQuote`, `CheckingBalance`, `Registering`, or `SigningUserTxs`. Once registration - and signing updates are durably accepted, its owner-scoped `AwaitingPayment` snapshot or backend + is in `CheckingQuote`, `CheckingBalance`, `Registering`, `SigningUserTxs`, or + `SubmittingUserTxs`. Once registration and signing updates are durably accepted, its owner-scoped + recovery snapshot or backend transaction record survives selection changes and is available again when that owner is selected. Ramp ephemeral storage is independent recovery custody and is never pruned or cleared by manager/child selection. diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index d2606fa8f..1cfe9695e 100644 --- a/docs/security-spec/02-signing-keys/ephemeral-accounts.md +++ b/docs/security-spec/02-signing-keys/ephemeral-accounts.md @@ -11,7 +11,7 @@ Ephemeral accounts are temporary blockchain accounts created per ramp operation. The SDK optionally stores ephemeral keys under `ephemerals_{rampId}.json` via the `storeEphemeralKeys` config option (defaults to `true`). Node.js writes a local JSON file; browser builds write the same plaintext JSON to same-origin localStorage. Alternatively, the integrator can configure `storeEphemeralKeysCallback`; the SDK then passes the same recovery material (`{ address, rampId, secret, type }` items) to that callback instead of the built-in storage, and `storeEphemeralKeys` has no effect. This lets integrations encrypt the keys or persist them in their own vault. -The frontend and dashboard store a backup of all ephemeral keypairs in separate same-origin localStorage maps, keyed by ramp ID (`rampEphemerals` for the widget and `vortex_dashboard_rampEphemerals` for the dashboard). The widget persists this through `PersistenceEffect` in `apps/frontend/src/contexts/rampState.tsx`. The dashboard writes a pending quote-keyed entry before registration, then rebinds it to the returned ramp ID. This archive is **not** cleared when ramp context, authentication, or other UI state resets. The purpose is a user-side failsafe: if a ramp fails mid-flow and the main state is wiped, the ephemeral secret keys remain recoverable from this separate localStorage entry. Once a client observes a terminal state it records `terminalObservedAt`; storage maintenance removes that entry on the first access at least 90 days later. Entries without a terminal observation are retained indefinitely (accepted as [RISK-006](../RISK-REGISTER.md)). The dashboard separately persists its serializable transfer-machine snapshot under `vortex-dashboard-transfer-state` so an onramp's server-issued payment instructions survive reload; reset/logout clears this snapshot but not the independent ephemeral archive. +The frontend and dashboard store a backup of all ephemeral keypairs in separate same-origin localStorage maps, keyed by ramp ID (`rampEphemerals` for the widget and `vortex_dashboard_rampEphemerals` for the dashboard). The widget persists this through `PersistenceEffect` in `apps/frontend/src/contexts/rampState.tsx`. The dashboard writes a pending quote-keyed entry before registration, then rebinds it to the returned ramp ID. This archive is **not** cleared when ramp context, authentication, or other UI state resets. The purpose is a user-side failsafe: if a ramp fails mid-flow and the main state is wiped, the ephemeral secret keys remain recoverable from this separate localStorage entry. Once a client observes a terminal state it records `terminalObservedAt`; storage maintenance removes that entry on the first access at least 90 days later. Entries without a terminal observation are retained indefinitely (accepted as [RISK-006](../RISK-REGISTER.md)). The dashboard separately persists its serializable transfer-machine snapshot under `vortex-dashboard-transfer-state` so an onramp's server-issued payment instructions, and an offramp's already-broadcast wallet output (user-signed permits and transaction hashes not yet accepted by `/ramp/update`), survive reload; the snapshot never holds ephemeral secrets, and reset/logout clears it but not the independent ephemeral archive. Frontend and SDK Substrate RPC clients are initialized lazily. Creating/importing frontend API services, mounting the Polkadot node provider, constructing the SDK, or entering ramp registration MUST NOT open Pendulum, Moonbeam, Hydration, or AssetHub websocket connections by default. A Substrate RPC may be opened only when the active UI component explicitly asks for that node, or after ramp registration returns unsigned transactions that actually require Substrate-format signing on that network. For example, a Polygon Monerium EUR onramp should not initialize Moonbeam solely because the frontend imports `services/api` or because the generic register actor runs. From 481d0cc34e082eb2b53220bd017dbf5b490a8594 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 12:32:24 +0200 Subject: [PATCH 5/6] refactor(dashboard): drop the unheard failure event and duplicate retry toast Nothing listens to TRANSFER_FAILED, and the retry panel already shows the error inline. --- .../dashboard/src/components/transfer/TransferForm.tsx | 1 - apps/dashboard/src/machines/transfer.machine.ts | 10 ++-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/dashboard/src/components/transfer/TransferForm.tsx b/apps/dashboard/src/components/transfer/TransferForm.tsx index dc02f82d7..839b4013a 100644 --- a/apps/dashboard/src/components/transfer/TransferForm.tsx +++ b/apps/dashboard/src/components/transfer/TransferForm.tsx @@ -386,7 +386,6 @@ function OfframpStartRetry({ ramp }: { ramp: RampProcess }) { navigate({ to: "/transactions" }); } else if (snapshot.matches("AwaitingRetry")) { subscription.unsubscribe(); - toast.error("Could not start transfer", { description: snapshot.context.errorMessage ?? undefined }); } }); transferActor.send({ ownerProfileId, type: "RETRY" }); diff --git a/apps/dashboard/src/machines/transfer.machine.ts b/apps/dashboard/src/machines/transfer.machine.ts index f6ac1d97e..44cb54622 100644 --- a/apps/dashboard/src/machines/transfer.machine.ts +++ b/apps/dashboard/src/machines/transfer.machine.ts @@ -421,18 +421,12 @@ export const transferMachine = setup({ // wallet has already broadcast: keep everything so the same update can be resent. onError: [ { - actions: [ - assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), - emit(({ event }) => ({ message: errorMessage(event.error), type: "TRANSFER_FAILED" as const })) - ], + actions: assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), guard: "isOnramp", target: "Failed" }, { - actions: [ - assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), - emit(({ event }) => ({ message: errorMessage(event.error), type: "TRANSFER_FAILED" as const })) - ], + actions: assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), target: "AwaitingRetry" } ], From 49ad9778804cbea1aceef768b5eec9709c314d8e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 6 Oct 2026 13:59:16 +0200 Subject: [PATCH 6/6] fix(dashboard): say when the api starts an expired offramp itself Since #1394 the recovery worker starts a non-domestic SELL whose source hash was reported once its start deadline passes, but the expired panel still said the transfer could no longer start and offered a new one, inviting a second payment. A start that succeeded before a reload looked the same. --- .../src/components/transfer/TransferForm.tsx | 33 ++++++++++++++++--- apps/dashboard/src/domain/transfer.test.ts | 20 +++++++++++ apps/dashboard/src/domain/transfer.ts | 11 ++++++- docs/product-dashboard.md | 11 ++++--- 4 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/src/domain/transfer.test.ts diff --git a/apps/dashboard/src/components/transfer/TransferForm.tsx b/apps/dashboard/src/components/transfer/TransferForm.tsx index 9bae0c178..bdd9bf337 100644 --- a/apps/dashboard/src/components/transfer/TransferForm.tsx +++ b/apps/dashboard/src/components/transfer/TransferForm.tsx @@ -22,7 +22,7 @@ import { CORRIDORS } from "@/domain/corridors"; import { getNetworkOptions, getRampTokenOptions } from "@/domain/onramp"; import { recipientLabel } from "@/domain/recipient"; import { RECIPIENT_STATUS_META } from "@/domain/status"; -import { PAYMENT_METHOD_LABEL } from "@/domain/transfer"; +import { offrampStartsAfterDeadline, PAYMENT_METHOD_LABEL } from "@/domain/transfer"; import type { CorridorId, Recipient, SenderAccount } from "@/domain/types"; import { MAINTENANCE_QUOTE_ERROR, useActiveMaintenance } from "@/hooks/useActiveMaintenance"; import { formatCurrencyAmount } from "@/lib/amount"; @@ -363,6 +363,13 @@ function OfframpStartRetry({ ramp }: { ramp: RampProcess }) { const navigate = useNavigate(); const busy = useSelector(transferActor, snapshot => !snapshot.matches("AwaitingRetry")); const startError = useSelector(transferActor, snapshot => snapshot.context.errorMessage); + // No wallet output left to submit means /ramp/update accepted the source hash. + const startsAutomatically = useSelector( + transferActor, + snapshot => + !!snapshot.context.quote && + offrampStartsAfterDeadline(snapshot.context.quote.outputCurrency, snapshot.context.userTxSubmission === null) + ); const [now, setNow] = useState(() => Date.now()); const deadline = ramp.expiresAt ? new Date(ramp.expiresAt).getTime() : Number.NaN; const expired = Number.isFinite(deadline) && deadline <= now; @@ -407,11 +414,27 @@ function OfframpStartRetry({ ramp }: { ramp: RampProcess }) {
-

The window to start this transfer has closed

+ {startsAutomatically ? ( + <> +

This transfer will start automatically

+

+ Your tokens left your wallet and Vortex has the transaction on record, so it starts this transfer on its own + within about 10 minutes. Don’t send this payment again. If it still hasn’t started after that, contact support + with transfer ID {ramp.id}. +

+ + ) : ( + <> +

The window to start this transfer has closed

+

+ Your tokens left your wallet, but the transfer can no longer be started. Contact support with transfer ID{" "} + {ramp.id}. +

+ + )}

- Your tokens left your wallet, but the transfer can no longer be started. Contact support with transfer ID{" "} - {ramp.id}, and don’t clear this browser’s data: it holds the keys - needed to recover your tokens. + Before sending this payment again, check Transactions: if this transfer shows as processing, it already started. + Don’t clear this browser’s data: it holds the keys needed to recover your tokens.

diff --git a/apps/dashboard/src/domain/transfer.test.ts b/apps/dashboard/src/domain/transfer.test.ts new file mode 100644 index 000000000..ccac07bf3 --- /dev/null +++ b/apps/dashboard/src/domain/transfer.test.ts @@ -0,0 +1,20 @@ +import { FiatToken } from "@vortexfi/shared"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { offrampStartsAfterDeadline } from "./transfer"; + +describe("offrampStartsAfterDeadline", () => { + it("is true for a non-domestic SELL whose source hash the API accepted", () => { + assert.equal(offrampStartsAfterDeadline(FiatToken.BRL, true), true); + }); + + it("is false while the source hash is still unsubmitted", () => { + assert.equal(offrampStartsAfterDeadline(FiatToken.BRL, false), false); + }); + + it("is false for AlfredPay pay-outs, which the recovery worker never starts", () => { + for (const currency of [FiatToken.USD, FiatToken.MXN, FiatToken.COP, FiatToken.ARS]) { + assert.equal(offrampStartsAfterDeadline(currency, true), false); + } + }); +}); diff --git a/apps/dashboard/src/domain/transfer.ts b/apps/dashboard/src/domain/transfer.ts index c11b7ea7f..e8261673f 100644 --- a/apps/dashboard/src/domain/transfer.ts +++ b/apps/dashboard/src/domain/transfer.ts @@ -1,4 +1,4 @@ -import { Networks } from "@vortexfi/shared"; +import { isDomesticToken, Networks, type RampCurrency } from "@vortexfi/shared"; import type { RecipientMethod } from "./types"; /** Display label for the fiat rail each corridor settles on. */ @@ -29,3 +29,12 @@ export function shortenAddress(address: string): string { } return `${address.slice(0, 6)}…${address.slice(-4)}`; } + +/** + * Whether the API starts a broadcast offramp on its own once its 15-minute start deadline has + * passed. The API's recovery worker does so for a non-domestic (non-AlfredPay) SELL whose source + * hash /ramp/update has already accepted. + */ +export function offrampStartsAfterDeadline(outputCurrency: RampCurrency, sourceHashAccepted: boolean): boolean { + return sourceHashAccepted && !isDomesticToken(outputCurrency); +} diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 17e0f78cb..1e74e5a1c 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -294,10 +294,13 @@ provider-shaped rather than UI-shaped. network error) moves to `AwaitingRetry` with the ramp and any unsubmitted signatures and hashes; **Try again** resends only the failed call and never re-signs or re-broadcasts. The pay-out form shows the ramp's 15-minute start deadline (`expiresAt`); once it passes, the API refuses the - start, so the dashboard shows the ramp ID for a support-led recovery from the local ephemeral - archive and allows starting a new transfer. The transactions page offers **Resume transfer** for - that state. A partially broadcast wallet sequence (one transaction sent, a later one rejected) - still ends in `Failed`. + start. If `/ramp/update` had already accepted the source hash of a non-domestic (non-AlfredPay) + SELL, the API's recovery worker starts the ramp itself shortly after the deadline, and the + dashboard says so and warns against paying again. Otherwise it shows the ramp ID for a + support-led recovery from the local ephemeral archive. Both views ask the user to check + Transactions before sending again and allow starting a new transfer. The transactions page + offers **Resume transfer** for that state. A partially broadcast wallet sequence (one + transaction sent, a later one rejected) still ends in `Failed`. - **Preserve ramp recovery keys.** The dashboard stores each ramp's EVM and Substrate ephemeral secrets locally before registration (under a