From dbebea0acdbde856b5b66b1072c705540256fe9c Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Mon, 15 Jun 2026 14:11:25 +0200 Subject: [PATCH] fix(widget): expose transaction signing errors --- .../src/domain/types/external-providers.ts | 31 +++++++-- .../widget/src/pages/steps/hooks/errors.ts | 12 +++- .../steps/hooks/use-steps-machine.hook.ts | 9 ++- .../src/pages/steps/hooks/use-steps.hook.ts | 15 +++++ .../src/pages/steps/pages/common.page.tsx | 25 ++++++-- .../src/pages/steps/pages/styles.css.ts | 7 +- .../widget/src/pages/steps/pages/tx-state.tsx | 2 +- .../widget/src/providers/sk-wallet/errors.ts | 5 +- .../widget/src/providers/sk-wallet/index.tsx | 10 ++- .../src/styles/tokens/colors/contract.ts | 1 + .../widget/src/styles/tokens/colors/values.ts | 2 + .../src/translation/English/translations.json | 2 +- .../src/translation/French/translations.json | 2 +- .../widget/tests/use-cases/sk-wallet.test.tsx | 64 +++++++++++++++++++ 14 files changed, 169 insertions(+), 18 deletions(-) diff --git a/packages/widget/src/domain/types/external-providers.ts b/packages/widget/src/domain/types/external-providers.ts index 6b9629a0..c6491775 100644 --- a/packages/widget/src/domain/types/external-providers.ts +++ b/packages/widget/src/domain/types/external-providers.ts @@ -3,6 +3,17 @@ import type { RefObject } from "react"; import type { SKExternalProviders } from "./wallets"; import type { SKTx, SKTxMeta } from "./wallets/generic-wallet"; +export class ExternalProviderError extends Error { + _tag = "ExternalProviderError"; + + constructor( + readonly customMessage: string | null, + cause?: unknown + ) { + super(customMessage ?? "External provider failed", { cause }); + } +} + export class ExternalProvider { constructor(private variantProvider: RefObject) {} @@ -13,8 +24,8 @@ export class ExternalProvider { ).toEither(new Error("Invalid provider type")) ) .chain((_sendTransaction) => - EitherAsync(() => _sendTransaction(tx, txMeta)).mapLeft( - () => new Error("Failed to send transaction, unknown error") + EitherAsync(() => _sendTransaction(tx, txMeta)).mapLeft((error) => + toExternalProviderError(error) ) ) .chain((res) => { @@ -26,7 +37,9 @@ export class ExternalProvider { return EitherAsync.liftEither(Right(res.txHash)); } - return EitherAsync.liftEither(Left(res.error)); + return EitherAsync.liftEither( + Left(new ExternalProviderError(res.error)) + ); }); } @@ -44,7 +57,17 @@ export class ExternalProvider { this.variantProvider.current.provider.signMessage(messageHash) ).mapLeft((e) => { console.error(e); - return new Error("Failed to sign message"); + return toExternalProviderError(e); }); } } + +const toExternalProviderError = (error: unknown) => + new ExternalProviderError( + error instanceof Error && error.message + ? error.message + : typeof error === "string" && error + ? error + : null, + error + ); diff --git a/packages/widget/src/pages/steps/hooks/errors.ts b/packages/widget/src/pages/steps/hooks/errors.ts index 9231bb96..ee723f93 100644 --- a/packages/widget/src/pages/steps/hooks/errors.ts +++ b/packages/widget/src/pages/steps/hooks/errors.ts @@ -2,13 +2,23 @@ export class SignError extends Error { _tag = "SignError"; txId: string; network: string; + customMessage: string | null; - constructor({ network, txId }: { txId: string; network: string }) { + constructor({ + customMessage = null, + network, + txId, + }: { + customMessage?: string | null; + txId: string; + network: string; + }) { super(); this._tag = "SignError"; this.txId = txId; this.network = network; + this.customMessage = customMessage; } } export class GetStakeSessionError extends Error { diff --git a/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts b/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts index 55c18bf5..eb489747 100644 --- a/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts +++ b/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts @@ -4,6 +4,7 @@ import { type RefObject, useMemo, useState } from "react"; import { assign, emit, setup } from "xstate"; import { isTxError } from "../../../domain"; import type { ActionDto, TransactionDto } from "../../../domain/types/action"; +import { ExternalProviderError } from "../../../domain/types/external-providers"; import type { ActionMeta } from "../../../domain/types/wallets/generic-wallet"; import { useTrackEvent } from "../../../hooks/tracking/use-track-event"; import { useSavedRef } from "../../../hooks/use-saved-ref"; @@ -25,7 +26,7 @@ type TxMeta = { url: string | null; signedTx: string | null; broadcasted: boolean | null; - signError: SendTransactionError | TransactionDecodeError | null; + signError: SendTransactionError | TransactionDecodeError | SignError | null; txCheckError: GetStakeSessionError | null; done: boolean; }; @@ -240,8 +241,12 @@ const getMachine = ( data: { signedTx: val, broadcasted: false }, })) .mapLeft( - () => + (error) => new SignError({ + customMessage: + error instanceof ExternalProviderError + ? error.customMessage + : null, network: tx.network, txId: tx.id, }) diff --git a/packages/widget/src/pages/steps/hooks/use-steps.hook.ts b/packages/widget/src/pages/steps/hooks/use-steps.hook.ts index d79607ad..34139cc3 100644 --- a/packages/widget/src/pages/steps/hooks/use-steps.hook.ts +++ b/packages/widget/src/pages/steps/hooks/use-steps.hook.ts @@ -168,6 +168,20 @@ export const useSteps = ({ ] ); + const customSignErrorMessage = useMemo(() => { + const error = machineState.context.currentTxMeta + .chainNullable((currentTxMeta) => { + return machineState.context.txStates[currentTxMeta.idx]?.meta.signError; + }) + .extractNullable(); + + if (!error || !("customMessage" in error)) return null; + + return typeof error.customMessage === "string" && error.customMessage + ? error.customMessage + : null; + }, [machineState.context.currentTxMeta, machineState.context.txStates]); + const { t } = useTranslation(); const onClickRef = useSavedRef(onClick); @@ -190,6 +204,7 @@ export const useSteps = ({ retry, txStates, cta, + customSignErrorMessage, }; }; diff --git a/packages/widget/src/pages/steps/pages/common.page.tsx b/packages/widget/src/pages/steps/pages/common.page.tsx index ce722ddb..bc1fed8e 100644 --- a/packages/widget/src/pages/steps/pages/common.page.tsx +++ b/packages/widget/src/pages/steps/pages/common.page.tsx @@ -12,7 +12,7 @@ import { useSettings } from "../../../providers/settings"; import { PageContainer } from "../../components/page-container"; import { PageCtaButton } from "../../components/page-cta"; import { useSteps } from "../hooks/use-steps.hook"; -import { utilaPendingApprovalsBanner } from "./styles.css"; +import { stepsErrorBanner, utilaPendingApprovalsBanner } from "./styles.css"; import { TxState } from "./tx-state"; type StepsPageProps = { @@ -28,9 +28,9 @@ export const StepsPage = ({ onSignSuccess, providersDetails, }: StepsPageProps) => { - const { variant } = useSettings(); + const { dashboardVariant, variant } = useSettings(); - const { retry, txStates, cta } = useSteps({ + const { retry, txStates, cta, customSignErrorMessage } = useSteps({ inputToken, session, onSignSuccess, @@ -48,6 +48,19 @@ export const StepsPage = ({ {t("steps.title")} + {customSignErrorMessage && ( + + + {customSignErrorMessage} + + + )} + {showUtilaPendingApprovals && ( - diff --git a/packages/widget/src/pages/steps/pages/styles.css.ts b/packages/widget/src/pages/steps/pages/styles.css.ts index 7f3d3f8d..27fa62e7 100644 --- a/packages/widget/src/pages/steps/pages/styles.css.ts +++ b/packages/widget/src/pages/steps/pages/styles.css.ts @@ -33,5 +33,10 @@ export const halfOpacityAfter = style({ ":after": { opacity: 0.5 } }); export const utilaPendingApprovalsBanner = style({ background: vars.color.warningBoxBackground, - borderRadius: "20px", + borderRadius: vars.borderRadius.baseContract.xl, +}); + +export const stepsErrorBanner = style({ + background: vars.color.errorBoxBackground, + borderRadius: vars.borderRadius.baseContract.xl, }); diff --git a/packages/widget/src/pages/steps/pages/tx-state.tsx b/packages/widget/src/pages/steps/pages/tx-state.tsx index 6ac39e16..84d61c36 100644 --- a/packages/widget/src/pages/steps/pages/tx-state.tsx +++ b/packages/widget/src/pages/steps/pages/tx-state.tsx @@ -132,7 +132,7 @@ export const TxState = ({ txState, position, count, session }: Props) => { {t("steps.approve")} {txState.state === TxStateEnum.SIGN_ERROR ? ( - {txState.meta.signError?.message || t("steps.approve_error")} + {t("steps.approve_error")} ) : ( diff --git a/packages/widget/src/providers/sk-wallet/errors.ts b/packages/widget/src/providers/sk-wallet/errors.ts index f3b4756b..82f8b75b 100644 --- a/packages/widget/src/providers/sk-wallet/errors.ts +++ b/packages/widget/src/providers/sk-wallet/errors.ts @@ -11,7 +11,10 @@ export class SafeFailedError extends Error { export class SendTransactionError extends Error { _tag = "SendTransactionError"; - constructor(cause?: unknown) { + constructor( + cause?: unknown, + readonly customMessage: string | null = null + ) { super("Send transaction failed", { cause }); this._tag = "SendTransactionError"; diff --git a/packages/widget/src/providers/sk-wallet/index.tsx b/packages/widget/src/providers/sk-wallet/index.tsx index f7f1d24e..896f5841 100644 --- a/packages/widget/src/providers/sk-wallet/index.tsx +++ b/packages/widget/src/providers/sk-wallet/index.tsx @@ -25,6 +25,7 @@ import { isTonChain, isTronChain, } from "../../domain/types/chains"; +import { ExternalProviderError } from "../../domain/types/external-providers"; import { decodeAndPrepareEvmTransaction, substratePayloadCodec, @@ -337,7 +338,10 @@ export const SKWalletProvider = ({ children }: PropsWithChildren) => { .mapLeft( (e) => new SendTransactionError( - typeof e === "string" ? e : undefined + e, + e instanceof ExternalProviderError + ? e.customMessage + : null ) ) ) @@ -510,7 +514,9 @@ export const SKWalletProvider = ({ children }: PropsWithChildren) => { }) .mapLeft((e) => { console.log(e); - return new Error("sign failed"); + return e instanceof ExternalProviderError + ? e + : new Error("sign failed"); }), [connectorDetails, signMessageAsync] ); diff --git a/packages/widget/src/styles/tokens/colors/contract.ts b/packages/widget/src/styles/tokens/colors/contract.ts index e91fc09f..1e7e88bb 100644 --- a/packages/widget/src/styles/tokens/colors/contract.ts +++ b/packages/widget/src/styles/tokens/colors/contract.ts @@ -32,6 +32,7 @@ const baseColorsContract = { selectValidatorMultiDefaultBackground: "", warningBoxBackground: "", + errorBoxBackground: "", positionsClaimRewardsBackground: "", positionsActionRequiredBackground: "", diff --git a/packages/widget/src/styles/tokens/colors/values.ts b/packages/widget/src/styles/tokens/colors/values.ts index 94b1d574..125999e4 100644 --- a/packages/widget/src/styles/tokens/colors/values.ts +++ b/packages/widget/src/styles/tokens/colors/values.ts @@ -144,6 +144,7 @@ export const lightThemeColors: typeof colorsContract = { skeletonLoaderHighlight: vars.color.background, warningBoxBackground: "#FFE9BD", + errorBoxBackground: sharedSemanticColors.status.danger, stakeSectionBackground: "#f5f5f6", @@ -221,6 +222,7 @@ export const darkThemeColors: typeof colorsContract = { skeletonLoaderHighlight: "#333333", warningBoxBackground: vars.color.backgroundMuted, + errorBoxBackground: sharedSemanticColors.status.danger, stakeSectionBackground: "#282828", diff --git a/packages/widget/src/translation/English/translations.json b/packages/widget/src/translation/English/translations.json index 9b337c17..2de21cc5 100644 --- a/packages/widget/src/translation/English/translations.json +++ b/packages/widget/src/translation/English/translations.json @@ -396,7 +396,7 @@ "skipped": "Skipped", "pending_approvals": "Pending approvals", "pending_approvals_desc": "Please open your wallet to review and securely sign the transaction", - "approve_error": "Something went wrong. Check if wallet is connected", + "approve_error": "Something went wrong", "tx_type": { "SWAP": "SWAP", "DEPOSIT": "DEPOSIT", diff --git a/packages/widget/src/translation/French/translations.json b/packages/widget/src/translation/French/translations.json index 9d159a69..c17a2d7a 100644 --- a/packages/widget/src/translation/French/translations.json +++ b/packages/widget/src/translation/French/translations.json @@ -270,7 +270,7 @@ "skipped": "Ignorée", "pending_approvals": "Approbations en attente", "pending_approvals_desc": "Ouvrez votre portefeuille pour vérifier et signer la transaction en toute sécurité", - "approve_error": "Une erreur s'est produite. Vérifiez si le portefeuille est bien connecté", + "approve_error": "Une erreur s'est produite", "tx_type": { "SWAP": "ÉCHANGE", "DEPOSIT": "DÉPÔT", diff --git a/packages/widget/tests/use-cases/sk-wallet.test.tsx b/packages/widget/tests/use-cases/sk-wallet.test.tsx index f44174b5..891fbd23 100644 --- a/packages/widget/tests/use-cases/sk-wallet.test.tsx +++ b/packages/widget/tests/use-cases/sk-wallet.test.tsx @@ -6,6 +6,7 @@ import { SKApiClientProvider } from "../../src/providers/api/api-client-provider import { SKQueryClientProvider } from "../../src/providers/query-client"; import { SettingsContextProvider } from "../../src/providers/settings"; import { SKWalletProvider, useSKWallet } from "../../src/providers/sk-wallet"; +import { SendTransactionError } from "../../src/providers/sk-wallet/errors"; import { SolanaProvider } from "../../src/providers/solana"; import { TrackingContextProviderWithProps } from "../../src/providers/tracking"; import { WagmiConfigProvider } from "../../src/providers/wagmi/provider"; @@ -116,6 +117,69 @@ describe("SK Wallet", () => { ); }); + it("preserves custom external provider transaction errors", async ({ + worker, + }) => { + const customMessage = "Transaction blocked by policy"; + const switchChainSpy = vi.fn(async (_: number) => {}); + const sendTransactionSpy = vi.fn(async () => ({ + type: "error" as const, + error: customMessage, + })); + + worker.use( + http.get(legacyApiRoute("/v1/yields/enabled/networks"), async () => { + await delay(); + return HttpResponse.json([MiscNetworks.Solana]); + }) + ); + + const solanaWallet = await renderHookWithExternalProvider({ + type: "generic", + currentAddress: "9TCnDo7Txc5bC9SnE9iKsU5CyffLfeK4nrv1BFUmxkiJ", + currentChain: solana.id, + supportedChainIds: [solana.id], + provider: { + signMessage: async () => "hash", + switchChain: switchChainSpy, + sendTransaction: sendTransactionSpy, + }, + }); + + await expect.poll(() => solanaWallet.result.current.isConnected).toBe(true); + + const solanaRes = await solanaWallet.result.current.signTransaction({ + network: "solana", + tx: "12345", + txMeta: { + txId: "", + actionId: "", + actionType: "STAKE", + txType: "APPROVAL", + amount: "100", + inputToken: { + address: "", + decimals: 0, + symbol: "", + name: "", + network: "solana", + }, + structuredTransaction: null, + annotatedTransaction: null, + providersDetails: [], + }, + ledgerHwAppId: null, + }); + + expect(solanaRes.isLeft()).toBe(true); + + const error = solanaRes.extract() as SendTransactionError; + + expect(error).toBeInstanceOf(SendTransactionError); + expect(error.message).toBe("Send transaction failed"); + expect(error.customMessage).toBe(customMessage); + }); + it("should work with ton external provider", async ({ worker }) => { const switchChainSpy = vi.fn(async (_: number) => {}); const sendTransactionSpy = vi.fn(async (_: unknown) => "hash");