diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 23fdc5a7d..cc4b7ad18 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Show pre-submit send and change-trust validation failures in the confirmation dialog, then return structured error codes (send) or rethrow (change-trust) ([#220](https://github.com/MetaMask/internal-snaps/pull/220)) + - Banner copy uses `confirmation.txnError.*` locale keys +- `onAmountInput` now always validates a self-transfer so destination errors surface in `confirmSend` ([#220](https://github.com/MetaMask/internal-snaps/pull/220)) - `createValidatedSendTransaction` now throws `InvalidAssetForCreateAccountException` instead of `AccountNotActivatedException` when sending a non-native asset to an unfunded destination ([#185](https://github.com/MetaMask/internal-snaps/pull/185)) ## [0.1.0] diff --git a/packages/stellar-wallet-snap/docs/use-cases/client-request/changeTrustOpt.md b/packages/stellar-wallet-snap/docs/use-cases/client-request/changeTrustOpt.md index f43277fe8..3ed011f77 100644 --- a/packages/stellar-wallet-snap/docs/use-cases/client-request/changeTrustOpt.md +++ b/packages/stellar-wallet-snap/docs/use-cases/client-request/changeTrustOpt.md @@ -25,7 +25,9 @@ Add or remove a classic Stellar trustline for an asset on a managed account. - `{ status: true }` — opt-in already satisfied (trustline exists with limit > 0), or became redundant while the dialog was open - `{ status: false }` — account not activated (funding prompt shown; not an RPC error) -User rejection of the confirmation dialog throws `UserRejectedRequestError`. +Pre-submit validation failures (missing trustline, non-zero opt-out balance, reserve, fee) are shown in the change-trust confirmation dialog first (no fee or price estimates). After the dialog closes, the handler rethrows the validation error. Failures after the user confirms rethrow without a second dialog. + +User rejection of a valid confirmation dialog throws `UserRejectedRequestError`. ## Participants @@ -49,12 +51,13 @@ User rejection of the confirmation dialog throws `UserRejectedRequestError`. 1. **Route** — `onClientRequest` dispatches to `ChangeTrustOptHandler`. 2. **Resolve** — `AccountResolver` loads keyring account, wallet, and activated on-chain account from the **live network**. Unfunded accounts show the activation prompt and return `{ status: false }`. -3. **Short-circuit** — If `add` and a trustline with limit > 0 already exists → `{ status: true }`. If `delete` and no trustline → `TrustlineNotFoundException`. +3. **Short-circuit** — If `add` and a trustline with limit > 0 already exists → `{ status: true }`. If `delete` and no trustline, the opt-out confirmation shows the error, then `TrustlineNotFoundException` is rethrown. 4. **Build** — Resolve asset metadata; `TransactionService.createValidatedChangeTrustTransaction` builds a change-trust op (`delete` forces limit `"0"`). -5. **Confirm** — `ConfirmationUXController` shows opt-in or opt-out UI (fee, security scan, local re-validation cron while open). -6. **Refresh** — After confirm, account is resolved again from the live network; fee must not exceed what the user approved; redundant opt-in returns `{ status: true }` without submit. -7. **Sign & send** — `Wallet.signTransaction` → `TransactionService.sendTransaction`. -8. **Post-submit** — Persist pending keyring tx (`ChangeTrustOptIn` / `ChangeTrustOptOut`) and schedule `TrackTransactionHandler`. +5. **Pre-submit validation errors** — Missing trustline, non-zero opt-out balance, reserve, and fee failures are shown in the change-trust confirmation (no fee or price estimates). After the dialog closes, the handler rethrows. +6. **Confirm** — `ConfirmationUXController` shows opt-in or opt-out UI (fee, security scan, local re-validation cron while open). +7. **Refresh** — After confirm, account is resolved again from the live network; fee must not exceed what the user approved; redundant opt-in returns `{ status: true }` without submit. Validation failures here rethrow without a second dialog. +8. **Sign & send** — `Wallet.signTransaction` → `TransactionService.sendTransaction`. +9. **Post-submit** — Persist pending keyring tx (`ChangeTrustOptIn` / `ChangeTrustOptOut`) and schedule `TrackTransactionHandler`. ## Sequence (happy path) diff --git a/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md b/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md index 26a1010a0..38600d825 100644 --- a/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md +++ b/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md @@ -23,7 +23,9 @@ Confirms and submits a send for Unified Non-EVM Send (live on-chain data at buil - `{ valid: true, errors: [], transactionId }` — confirmed, signed, and submitted - `{ valid: false, errors: [{ code }] }` — `Invalid` · `InsufficientBalance` · `InsufficientBalanceToCoverFee` -User rejection of the confirmation dialog throws `UserRejectedRequestError`. Unactivated accounts return `{ valid: false, errors: [{ code: "Invalid" }] }` (no activation prompt). +Pre-submit validation failures (balance, memo, trustline, create-account, expired transaction, non-native send to an unfunded destination) are shown in the send confirmation dialog first (no fee or price estimates). After the dialog closes, the handler returns the error codes above. Failures after the user confirms return those same codes without a second dialog. + +User rejection of a valid confirmation dialog throws `UserRejectedRequestError`. Unactivated sender accounts show the account activation prompt and rethrow `AccountNotActivatedException`. ## Participants @@ -48,10 +50,11 @@ User rejection of the confirmation dialog throws `UserRejectedRequestError`. Una 1. **Route** — `onClientRequest` dispatches to `ConfirmSendHandler`. 2. **Resolve** — `AccountResolver` loads keyring account, wallet, and activated on-chain account from the **live network**. 3. **Build** — Resolve asset metadata; convert amount; `TransactionService.createValidatedSendTransaction`. -4. **Confirm** — `ConfirmationUXController` shows send UI (fee, estimated changes, security scan, local re-validation cron while open). -5. **Refresh** — After confirm, account is resolved again from the live network; fee must not exceed what the user approved. -6. **Sign & send** — `Wallet.signTransaction` → `TransactionService.sendTransaction`. -7. **Post-submit** — Persist pending keyring tx (`Send`) and schedule `TrackTransactionHandler` for sender + destination. +4. **Pre-submit validation errors** — Balance, memo, trustline, and create-account failures are shown in the send confirmation (no fee or price estimates). After the dialog closes, the handler returns `{ valid: false, errors: [{ code }] }`. +5. **Confirm** — `ConfirmationUXController` shows send UI (fee, estimated changes, security scan, local re-validation cron while open). +6. **Refresh** — After confirm, account is resolved again from the live network; fee must not exceed what the user approved. Validation failures here return error codes without a second dialog. +7. **Sign & send** — `Wallet.signTransaction` → `TransactionService.sendTransaction`. +8. **Post-submit** — Persist pending keyring tx (`Send`) and schedule `TrackTransactionHandler` for sender + destination. ## Sequence (happy path) diff --git a/packages/stellar-wallet-snap/docs/use-cases/client-request/onAmountInput.md b/packages/stellar-wallet-snap/docs/use-cases/client-request/onAmountInput.md index eb3a757cc..36b5f1cdb 100644 --- a/packages/stellar-wallet-snap/docs/use-cases/client-request/onAmountInput.md +++ b/packages/stellar-wallet-snap/docs/use-cases/client-request/onAmountInput.md @@ -16,7 +16,7 @@ Preflight-validates a send amount while the user types (balance and fee checks o - `accountId` — keyring account UUID - `assetId` — CAIP-19 classic / SEP-41 / slip44 asset (`scope` is derived from `assetId`) - `value` — positive amount string (human-readable units) -- `to` — optional Stellar destination; omitted → self-transfer validation +- `to` — optional Stellar destination; ignored for preflight (always a self-transfer so destination errors surface in `confirmSend`) **Response** @@ -42,7 +42,7 @@ Unactivated accounts return `{ valid: false, errors: [{ code: "Invalid" }] }` (n 1. **Route** — `onClientRequest` dispatches to `OnAmountInputHandler`. 2. **Resolve** — `AccountResolver` loads keyring account, wallet, and on-chain account from snap state. 3. **Convert** — Resolve asset metadata; convert `value` to smallest units; reject if sub-unit decimals remain. -4. **Preflight** — `TransactionService.createValidatedSendTransaction` with cached network reads (destination defaults to sender). +4. **Preflight** — `TransactionService.createValidatedSendTransaction` with cached network reads (destination is always the sender). 5. **Return** — Structured validation result; expected balance/fee failures are returned as error codes (not thrown). ## Note: cache usage diff --git a/packages/stellar-wallet-snap/locales/en.json b/packages/stellar-wallet-snap/locales/en.json index 608a95695..8240eff10 100644 --- a/packages/stellar-wallet-snap/locales/en.json +++ b/packages/stellar-wallet-snap/locales/en.json @@ -148,11 +148,47 @@ "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, - "confirmation.transactionInvalidTitle": { - "message": "Transaction is no longer valid" + "confirmation.txnError.requiresMemo": { + "message": "This account requires a memo. Sends to it are not supported." }, - "confirmation.transactionInvalidSubtitle": { - "message": "It may have expired or your account balance changed. Close this request and try again." + "confirmation.txnError.invalidCreateAccountAmount": { + "message": "Update Send Amount. A new account requires 1 XLM minimum." + }, + "confirmation.txnError.invalidCreateAccountAsset": { + "message": "Update Send Asset. New accounts must first be funded by 1 XLM." + }, + "confirmation.txnError.trustlineNotAuthorized": { + "message": "The destination is not authorized to receive this asset." + }, + "confirmation.txnError.trustlineNotFound": { + "message": "The destination has not activated a trustline for this asset." + }, + "confirmation.txnError.trustlineNotFoundOnAccount": { + "message": "This account has not activated a trustline for this asset." + }, + "confirmation.txnError.trustlineExceedLimit": { + "message": "This payment would exceed the destination's trustline limit." + }, + "confirmation.txnError.insufficientBalance": { + "message": "Insufficient balance for this transaction." + }, + "confirmation.txnError.insufficientBalanceToCoverFee": { + "message": "Insufficient balance to cover the network fee." + }, + "confirmation.txnError.generic": { + "message": "This transaction cannot be completed. Close this request and try again." + }, + "confirmation.txnError.trustlineNonZeroBalance": { + "message": "Send or swap the remaining balance before removing this trustline." + }, + "confirmation.txnError.updateTrustlineLimit": { + "message": "The new trustline limit cannot be below the current balance." + }, + "confirmation.txnError.insufficientBalanceToCoverBaseReserve": { + "message": "Insufficient balance to cover the account reserve." + }, + "confirmation.txnError.expired": { + "message": "This transaction has expired. Close this request and try again." }, "confirmation.validationScanErrorTitle": { "message": "Security check unavailable" diff --git a/packages/stellar-wallet-snap/locales/es.json b/packages/stellar-wallet-snap/locales/es.json index bbb11a880..67efdc4a9 100644 --- a/packages/stellar-wallet-snap/locales/es.json +++ b/packages/stellar-wallet-snap/locales/es.json @@ -148,11 +148,47 @@ "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, - "confirmation.transactionInvalidTitle": { - "message": "Transaction is no longer valid" + "confirmation.txnError.requiresMemo": { + "message": "This account requires a memo. Sends to it are not supported." }, - "confirmation.transactionInvalidSubtitle": { - "message": "It may have expired or your account balance changed. Close this request and try again." + "confirmation.txnError.invalidCreateAccountAmount": { + "message": "Update Send Amount. A new account requires 1 XLM minimum." + }, + "confirmation.txnError.invalidCreateAccountAsset": { + "message": "Update Send Asset. New accounts must first be funded by 1 XLM." + }, + "confirmation.txnError.trustlineNotAuthorized": { + "message": "The destination is not authorized to receive this asset." + }, + "confirmation.txnError.trustlineNotFound": { + "message": "The destination has not activated a trustline for this asset." + }, + "confirmation.txnError.trustlineNotFoundOnAccount": { + "message": "This account has not activated a trustline for this asset." + }, + "confirmation.txnError.trustlineExceedLimit": { + "message": "This payment would exceed the destination's trustline limit." + }, + "confirmation.txnError.insufficientBalance": { + "message": "Insufficient balance for this transaction." + }, + "confirmation.txnError.insufficientBalanceToCoverFee": { + "message": "Insufficient balance to cover the network fee." + }, + "confirmation.txnError.generic": { + "message": "This transaction cannot be completed. Close this request and try again." + }, + "confirmation.txnError.trustlineNonZeroBalance": { + "message": "Send or swap the remaining balance before removing this trustline." + }, + "confirmation.txnError.updateTrustlineLimit": { + "message": "The new trustline limit cannot be below the current balance." + }, + "confirmation.txnError.insufficientBalanceToCoverBaseReserve": { + "message": "Insufficient balance to cover the account reserve." + }, + "confirmation.txnError.expired": { + "message": "This transaction has expired. Close this request and try again." }, "confirmation.validationScanErrorTitle": { "message": "Security check unavailable" diff --git a/packages/stellar-wallet-snap/messages.json b/packages/stellar-wallet-snap/messages.json index b73da4399..70ccd2e49 100644 --- a/packages/stellar-wallet-snap/messages.json +++ b/packages/stellar-wallet-snap/messages.json @@ -146,11 +146,47 @@ "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, - "confirmation.transactionInvalidTitle": { - "message": "Transaction is no longer valid" + "confirmation.txnError.requiresMemo": { + "message": "This account requires a memo. Sends to it are not supported." }, - "confirmation.transactionInvalidSubtitle": { - "message": "It may have expired or your account balance changed. Close this request and try again." + "confirmation.txnError.invalidCreateAccountAmount": { + "message": "Update Send Amount. A new account requires 1 XLM minimum." + }, + "confirmation.txnError.invalidCreateAccountAsset": { + "message": "Update Send Asset. New accounts must first be funded by 1 XLM." + }, + "confirmation.txnError.trustlineNotAuthorized": { + "message": "The destination is not authorized to receive this asset." + }, + "confirmation.txnError.trustlineNotFound": { + "message": "The destination has not activated a trustline for this asset." + }, + "confirmation.txnError.trustlineNotFoundOnAccount": { + "message": "This account has not activated a trustline for this asset." + }, + "confirmation.txnError.trustlineExceedLimit": { + "message": "This payment would exceed the destination's trustline limit." + }, + "confirmation.txnError.insufficientBalance": { + "message": "Insufficient balance for this transaction." + }, + "confirmation.txnError.insufficientBalanceToCoverFee": { + "message": "Insufficient balance to cover the network fee." + }, + "confirmation.txnError.generic": { + "message": "This transaction cannot be completed. Close this request and try again." + }, + "confirmation.txnError.trustlineNonZeroBalance": { + "message": "Send or swap the remaining balance before removing this trustline." + }, + "confirmation.txnError.updateTrustlineLimit": { + "message": "The new trustline limit cannot be below the current balance." + }, + "confirmation.txnError.insufficientBalanceToCoverBaseReserve": { + "message": "Insufficient balance to cover the account reserve." + }, + "confirmation.txnError.expired": { + "message": "This transaction has expired. Close this request and try again." }, "confirmation.validationScanErrorTitle": { "message": "Security check unavailable" diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index 4c877cf20..c3fdb4bfb 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "XmHWNIVVLlhvKq+iksu3PyMYqx95Zj7lQAB/s1cOFlk=", + "shasum": "jUXDatd/u/35x3ftDRxgrJH2bTn7lfuSxJk5dGFbqgc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index 491d86015..a9c3d5053 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -35,13 +35,17 @@ import { createMockTransactionService, } from '../../services/transaction/__mocks__/transaction.fixtures'; import { + RemoveTrustlineWithNonZeroBalanceException, TransactionValidationException, TrustlineNotFoundException, } from '../../services/transaction/exceptions'; import { KeyringTransactionType } from '../../services/transaction/KeyringTransactionBuilder'; import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; -import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import { + ConfirmationInterfaceKey, + FetchStatus, +} from '../../ui/confirmation/api'; import { ConfirmationUXController } from '../../ui/confirmation/controller'; import { render as renderAccountActivationPrompt } from '../../ui/confirmation/views/AccountActivationPrompt/render'; import { logger } from '../../utils/logger'; @@ -349,7 +353,7 @@ describe('ChangeTrustOptHandler', () => { const result = await handler.handle(addRequest); expect(result).toStrictEqual({ status: true }); - expect(resolve).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledWith(assetId); expect(createValidatedChangeTrustTransaction).not.toHaveBeenCalled(); expect(renderConfirmationDialog).not.toHaveBeenCalled(); expect(signTransactionSpy).not.toHaveBeenCalled(); @@ -410,9 +414,11 @@ describe('ChangeTrustOptHandler', () => { ).not.toHaveBeenCalled(); }); - it('throws TrustlineNotFoundException for opt-out when trustline does not exist', async () => { + it('shows the opt-out confirmation then throws when the trustline does not exist', async () => { const { handler, + account, + assetMetadata, resolve, createValidatedChangeTrustTransaction, renderConfirmationDialog, @@ -424,13 +430,105 @@ describe('ChangeTrustOptHandler', () => { TrustlineNotFoundException, ); - expect(resolve).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledWith(assetId); + expect(renderConfirmationDialog).toHaveBeenCalledWith({ + origin: METAMASK_ORIGIN, + scope, + interfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptOut, + fee: '', + renderContext: { + account, + assetMetadata, + transactionsFetchStatus: FetchStatus.Error, + errorMessage: 'confirmation.txnError.trustlineNotFoundOnAccount', + }, + renderOptions: { + loadPrice: false, + securityScanning: false, + localSimulation: false, + }, + }); expect(createValidatedChangeTrustTransaction).not.toHaveBeenCalled(); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); expect(sendTransaction).not.toHaveBeenCalled(); expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); }); + it('shows the opt-out confirmation then throws when the trustline balance is non-zero', async () => { + const { + handler, + account, + assetMetadata, + createValidatedChangeTrustTransaction, + renderConfirmationDialog, + sendTransaction, + } = setup({ withTrustline: true }); + createValidatedChangeTrustTransaction.mockRejectedValueOnce( + new RemoveTrustlineWithNonZeroBalanceException('balance'), + ); + + await expect(handler.handle(deleteRequest)).rejects.toThrow( + RemoveTrustlineWithNonZeroBalanceException, + ); + + expect(renderConfirmationDialog).toHaveBeenCalledWith({ + origin: METAMASK_ORIGIN, + scope, + interfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptOut, + fee: '', + renderContext: { + account, + assetMetadata, + transactionsFetchStatus: FetchStatus.Error, + errorMessage: 'confirmation.txnError.trustlineNonZeroBalance', + }, + renderOptions: { + loadPrice: false, + securityScanning: false, + localSimulation: false, + }, + }); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it('shows the opt-in confirmation then throws when pre-submit validation fails', async () => { + const { + handler, + account, + assetMetadata, + createValidatedChangeTrustTransaction, + renderConfirmationDialog, + signTransactionSpy, + sendTransaction, + } = setup(); + createValidatedChangeTrustTransaction.mockRejectedValueOnce( + new TransactionValidationException('x'), + ); + + await expect(handler.handle(addRequest)).rejects.toThrow( + TransactionValidationException, + ); + + expect(renderConfirmationDialog).toHaveBeenCalledWith({ + origin: METAMASK_ORIGIN, + scope, + interfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptIn, + fee: '', + renderContext: { + account, + assetMetadata, + transactionsFetchStatus: FetchStatus.Error, + errorMessage: 'confirmation.txnError.generic', + }, + renderOptions: { + loadPrice: false, + securityScanning: false, + localSimulation: false, + }, + }); + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + it('handles changeTrust opt-out and enforces delete limit to 0', async () => { const { handler, diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 1d35a3ee5..591d4a486 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -11,15 +11,18 @@ import type { import type { AccountNotActivatedException } from '../../services/network'; import type { OnChainAccount } from '../../services/on-chain-account'; import { - TrustlineNotFoundException, KeyringTransactionType, - RemoveTrustlineWithNonZeroBalanceException, + TransactionValidationException, + TrustlineNotFoundException, } from '../../services/transaction'; import type { Transaction, TransactionService, } from '../../services/transaction'; -import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import { + ConfirmationInterfaceKey, + FetchStatus, +} from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { render as renderAccountActivationPrompt } from '../../ui/confirmation/views/AccountActivationPrompt/render'; import { @@ -42,7 +45,10 @@ import { ChangeTrustOptJsonRpcResponseStruct, } from './api'; import { BaseClientRequestHandler } from './base'; -import { assertRefreshedTransactionFeeNotHigher } from './utils'; +import { + assertRefreshedTransactionFeeNotHigher, + getTxnErrorMessageKey, +} from './utils'; export class ChangeTrustOptHandler extends BaseClientRequestHandler< ChangeTrustOptJsonRpcRequest, @@ -98,12 +104,18 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< /** * Handles trustline opt-in/opt-out requests. * + * Pre-submit validation failures (missing trustline, non-zero opt-out + * balance, reserve, fee) are shown in the change-trust confirmation first, + * then rethrown. Failures after the user confirms rethrow without a second + * dialog. + * * @param resolvedAccount - The resolved and activated account. * @param request - JSON-RPC request containing `scope`, `assetId`, `action`, and optional `limit`. * @returns A `ChangeTrustOptJsonRpcResponse`: * - `{ status: true, transactionId }` when the transaction is built, signed, and submitted. * - `{ status: true }` when preflight finds an existing classic trustline with limit greater than zero for an add request. * @throws {TrustlineNotFoundException} If a delete request targets a trustline that does not exist. + * @throws {TransactionValidationException} If pre-submit or post-confirm validation fails. * @throws {UserRejectedRequestError} If the user rejects the confirmation prompt. */ protected async execute( @@ -113,24 +125,37 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< const { scope, assetId, action } = request.params; const { account, onChainAccount } = resolvedAccount; - // Quit early if the opt-in is already redundant (throws for a missing opt-out trustline). - if (!this.#isChangeTrustOpNeeded(onChainAccount, request)) { - return { - status: true, - }; - } - // Safeguard to ensure we use the correct limit for delete const limitForTx = action === ChangeTrustOptAction.Delete ? '0' : request.params.limit; const assetMetadata = await this.#assetMetadataService.resolve(assetId); - const transaction = await this.#createTransaction({ - request, - onChainAccount, - limit: limitForTx, - }); + let transaction: Transaction; + try { + // Quit early if the opt-in is already redundant (throws for a missing opt-out trustline). + if (!this.#isChangeTrustOpNeeded(onChainAccount, request)) { + return { + status: true, + }; + } + + transaction = await this.#createTransaction({ + request, + onChainAccount, + limit: limitForTx, + }); + } catch (error: unknown) { + if (error instanceof TransactionValidationException) { + await this.#displayDialogWithErrorMessage({ + request, + account, + assetMetadata, + error, + }); + } + throw error; + } await trackTransactionAdded({ origin: METAMASK_ORIGIN, @@ -387,6 +412,47 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< ); } + /** + * Shows the change-trust confirmation with the validation error and no + * fee/price estimates, so the user can see why the request cannot proceed. + * + * @param params - The change-trust request context and validation error. + * @param params.request - The original changeTrustOpt JSON-RPC request. + * @param params.account - The sender keyring account. + * @param params.assetMetadata - Metadata for the asset being opted in or out. + * @param params.error - The pre-submit validation error to display. + */ + async #displayDialogWithErrorMessage(params: { + request: ChangeTrustOptJsonRpcRequest; + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + error: TransactionValidationException; + }): Promise { + const { request, account, assetMetadata, error } = params; + const { scope, action } = request.params; + + await this.#confirmationUIController.renderConfirmationDialog({ + origin: METAMASK_ORIGIN, + scope, + renderContext: { + account, + assetMetadata, + transactionsFetchStatus: FetchStatus.Error, + errorMessage: getTxnErrorMessageKey(error, account.address), + }, + fee: '', + interfaceKey: + action === ChangeTrustOptAction.Delete + ? ConfirmationInterfaceKey.ChangeTrustlineOptOut + : ConfirmationInterfaceKey.ChangeTrustlineOptIn, + renderOptions: { + loadPrice: false, + securityScanning: false, + localSimulation: false, + }, + }); + } + async #createTransaction(params: { request: ChangeTrustOptJsonRpcRequest; onChainAccount: OnChainAccount; @@ -400,19 +466,11 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< limit, } = params; - try { - return this.#transactionService.createValidatedChangeTrustTransaction({ - onChainAccount, - assetId, - scope, - limit, - }); - } catch (error: unknown) { - if (error instanceof RemoveTrustlineWithNonZeroBalanceException) { - // TODO: Display a alert for showing user balance and error message (TBC) - throw error; - } - throw error; - } + return this.#transactionService.createValidatedChangeTrustTransaction({ + onChainAccount, + assetId, + scope, + limit, + }); } } diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index b29e69394..130eef3cd 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -41,14 +41,25 @@ import { import { InsufficientBalanceException, InsufficientBalanceToCoverFeeException, + InvalidAmountForCreateAccountException, + InvalidAssetForCreateAccountException, + RequiresMemoException, + TransactionExpireException, TransactionValidationException, + TrustlineExceedLimitException, + TrustlineNotAuthorizedException, + TrustlineNotFoundException, XdrParseException, } from '../../services/transaction/exceptions'; import { KeyringTransactionType } from '../../services/transaction/KeyringTransactionBuilder'; import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; -import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import { + ConfirmationInterfaceKey, + FetchStatus, +} from '../../ui/confirmation/api'; import { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { render as renderAccountActivationPrompt } from '../../ui/confirmation/views/AccountActivationPrompt/render'; import * as errorUtils from '../../utils/errors'; import { logger } from '../../utils/logger'; import * as snapUtils from '../../utils/snap'; @@ -475,66 +486,183 @@ describe('ConfirmSendHandler', () => { expect(sendTransaction).not.toHaveBeenCalled(); }); - it('returns insufficient balance when createValidatedSendTransaction throws InsufficientBalanceException', async () => { - const { handler, createValidatedSendTransaction } = setup(); - createValidatedSendTransaction.mockRejectedValueOnce( - new InsufficientBalanceException('0', '1'), + describe('pre-submit validation errors', () => { + it.each([ + { + error: new InsufficientBalanceException('0', '1'), + code: MultiChainSendErrorCodes.InsufficientBalance, + message: 'confirmation.txnError.insufficientBalance', + }, + { + error: new InsufficientBalanceToCoverFeeException('0', '1'), + code: MultiChainSendErrorCodes.InsufficientBalanceToCoverFee, + message: 'confirmation.txnError.insufficientBalanceToCoverFee', + }, + { + error: new RequiresMemoException(destinationAddress), + code: MultiChainSendErrorCodes.Invalid, + message: 'confirmation.txnError.requiresMemo', + }, + { + error: new InvalidAmountForCreateAccountException('0.5'), + code: MultiChainSendErrorCodes.Invalid, + message: 'confirmation.txnError.invalidCreateAccountAmount', + }, + { + error: new InvalidAssetForCreateAccountException(assetId), + code: MultiChainSendErrorCodes.Invalid, + message: 'confirmation.txnError.invalidCreateAccountAsset', + }, + { + error: new TrustlineNotAuthorizedException(assetId, destinationAddress), + code: MultiChainSendErrorCodes.Invalid, + message: 'confirmation.txnError.trustlineNotAuthorized', + }, + { + error: new TrustlineNotFoundException(assetId, destinationAddress), + code: MultiChainSendErrorCodes.Invalid, + message: 'confirmation.txnError.trustlineNotFound', + }, + { + error: new TrustlineExceedLimitException(assetId), + code: MultiChainSendErrorCodes.Invalid, + message: 'confirmation.txnError.trustlineExceedLimit', + }, + { + error: new TransactionExpireException(0), + code: MultiChainSendErrorCodes.Invalid, + message: 'confirmation.txnError.expired', + }, + { + error: new TransactionValidationException('x'), + code: MultiChainSendErrorCodes.Invalid, + message: 'confirmation.txnError.generic', + }, + ])( + 'shows the send confirmation with $message then returns $code', + async ({ error, code, message }) => { + const { + handler, + account, + assetMetadata, + createValidatedSendTransaction, + renderConfirmationDialog, + signTransactionSpy, + sendTransaction, + } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce(error); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code }], + }); + expect(renderConfirmationDialog).toHaveBeenCalledWith({ + scope, + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + fee: '', + origin: METAMASK_ORIGIN, + renderContext: { + account, + toAddress: destinationAddress, + transactionsFetchStatus: FetchStatus.Error, + errorMessage: message, + }, + renderOptions: { + loadPrice: false, + securityScanning: false, + localSimulation: false, + }, + initialScan: { + status: 'ERROR', + estimatedChanges: { + assets: [ + { + type: AssetChangeDirection.Out, + value: '1', + price: null, + symbol: assetMetadata.symbol, + name: assetMetadata.name, + logo: assetMetadata.iconUrl, + }, + ], + }, + validation: null, + error: null, + }, + tokenPrices: { + [assetId]: null, + }, + }); + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }, ); - expect(await handler.handle(baseRequest())).toStrictEqual({ - valid: false, - errors: [{ code: MultiChainSendErrorCodes.InsufficientBalance }], - }); - }); - - it('returns insufficient balance to cover fee when createValidatedSendTransaction throws InsufficientBalanceToCoverFeeException', async () => { - const { handler, createValidatedSendTransaction } = setup(); - createValidatedSendTransaction.mockRejectedValueOnce( - new InsufficientBalanceToCoverFeeException('0', '1'), - ); + it('returns the error code after the user dismisses the validation confirmation', async () => { + const { + handler, + createValidatedSendTransaction, + renderConfirmationDialog, + } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new RequiresMemoException(destinationAddress), + ); + renderConfirmationDialog.mockResolvedValue(false); - expect(await handler.handle(baseRequest())).toStrictEqual({ - valid: false, - errors: [ - { code: MultiChainSendErrorCodes.InsufficientBalanceToCoverFee }, - ], + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); }); }); - it('returns invalid when createValidatedSendTransaction throws TransactionValidationException', async () => { - const { handler, createValidatedSendTransaction } = setup(); - createValidatedSendTransaction.mockRejectedValueOnce( - new TransactionValidationException('x'), - ); + it('returns error codes without a second confirmation when refresh fails after approval', async () => { + const { + handler, + transaction, + createValidatedSendTransaction, + renderConfirmationDialog, + signTransactionSpy, + sendTransaction, + } = setup(); + createValidatedSendTransaction + .mockResolvedValueOnce(transaction) + .mockRejectedValueOnce(new InsufficientBalanceException('0', '1')); expect(await handler.handle(baseRequest())).toStrictEqual({ valid: false, - errors: [{ code: MultiChainSendErrorCodes.Invalid }], + errors: [{ code: MultiChainSendErrorCodes.InsufficientBalance }], }); - }); - - it('returns invalid when createValidatedSendTransaction throws AccountNotActivatedException', async () => { - const { handler, createValidatedSendTransaction, wallet } = setup(); - createValidatedSendTransaction.mockRejectedValueOnce( - new AccountNotActivatedException(wallet.address, scope), + expect(renderConfirmationDialog).toHaveBeenCalledTimes(1); + expect(renderConfirmationDialog).toHaveBeenCalledWith( + expect.objectContaining({ + renderOptions: { + loadPrice: true, + securityScanning: true, + localSimulation: true, + }, + }), ); - - expect(await handler.handle(baseRequest())).toStrictEqual({ - valid: false, - errors: [{ code: MultiChainSendErrorCodes.Invalid }], - }); + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); }); - it('returns invalid when on-chain account is not activated', async () => { - const { handler, resolveOnChainAccountSpy, wallet } = setup(); + it('shows the account activation prompt when the sender account is not activated', async () => { + const { + handler, + resolveOnChainAccountSpy, + renderConfirmationDialog, + wallet, + } = setup(); resolveOnChainAccountSpy.mockRejectedValueOnce( new AccountNotActivatedException(wallet.address, scope), ); - expect(await handler.handle(baseRequest())).toStrictEqual({ - valid: false, - errors: [{ code: MultiChainSendErrorCodes.Invalid }], - }); + await expect(handler.handle(baseRequest())).rejects.toThrow( + AccountNotActivatedException, + ); + expect(renderAccountActivationPrompt).toHaveBeenCalledWith(wallet.address); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); it('returns invalid and tracks when createValidatedSendTransaction throws XdrParseException', async () => { diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index ac7506462..3d913e1fa 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -10,12 +10,11 @@ import type { AssetMetadataService, StellarAssetMetadata, } from '../../services/asset-metadata'; -import { AccountNotActivatedException } from '../../services/network'; import { InsufficientBalanceException, InsufficientBalanceToCoverFeeException, - TransactionValidationException, KeyringTransactionType, + TransactionValidationException, } from '../../services/transaction'; import type { Transaction, @@ -24,7 +23,10 @@ import type { import { AssetChangeDirection } from '../../services/transaction-scan'; import type { TransactionScanEstimatedChanges } from '../../services/transaction-scan'; import type { ContextWithPrices } from '../../ui/confirmation/api'; -import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import { + ConfirmationInterfaceKey, + FetchStatus, +} from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { hasDecimals, @@ -50,7 +52,10 @@ import { MultiChainSendErrorCodes, } from './api'; import { BaseClientRequestHandler } from './base'; -import { assertRefreshedTransactionFeeNotHigher } from './utils'; +import { + assertRefreshedTransactionFeeNotHigher, + getTxnErrorMessageKey, +} from './utils'; /** * Confirms and submits a send transaction for Unified Non-EVM Send. @@ -100,6 +105,12 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< /** * Builds a validated send transaction, shows confirmation, then signs and submits. * + * Pre-submit validation failures (balance, memo, trustline, create-account, + * expired transaction, non-native send to an unfunded destination) are shown + * in the send confirmation dialog first, then returned as structured error + * codes. Failures after the user confirms return those same codes without a + * second dialog. + * * @param resolved - Keyring account, live on-chain snapshot, and wallet. * @param request - JSON-RPC request with send params (`scope` is derived from `assetId`). * @returns `{ valid: true, errors: [], transactionId }` on success, or `{ valid: false, errors }` for validation failures. @@ -127,14 +138,28 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< }; } - const transaction = - await this.#transactionService.createValidatedSendTransaction({ - onChainAccount, - scope, - assetId, - amount: amountInSmallestUnit, - destination: toAddress, - }); + let transaction: Transaction; + try { + transaction = + await this.#transactionService.createValidatedSendTransaction({ + onChainAccount, + scope, + assetId, + amount: amountInSmallestUnit, + destination: toAddress, + }); + } catch (error: unknown) { + if (error instanceof TransactionValidationException) { + await this.#displayDialogWithErrorMessage({ + request, + account: stellarKeyringAccount, + assetMetadata, + scope, + error, + }); + } + throw error; + } await trackTransactionAdded({ origin: METAMASK_ORIGIN, @@ -229,10 +254,7 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< ], }; } - if ( - error instanceof TransactionValidationException || - error instanceof AccountNotActivatedException - ) { + if (error instanceof TransactionValidationException) { return { valid: false, errors: [{ code: MultiChainSendErrorCodes.Invalid }], @@ -352,6 +374,59 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< ); } + /** + * Shows the send confirmation with the validation error and no fee/price + * estimates, so the user can see why the send cannot proceed. + * + * @param params - The send request context and validation error. + * @param params.request - The original confirmSend JSON-RPC request. + * @param params.account - The sender keyring account. + * @param params.assetMetadata - Metadata for the asset being sent. + * @param params.scope - CAIP-2 chain of the send. + * @param params.error - The pre-submit validation error to display. + */ + async #displayDialogWithErrorMessage(params: { + request: ConfirmSendJsonRpcRequest; + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + scope: KnownCaip2ChainId; + error: TransactionValidationException; + }): Promise { + const { request, account, assetMetadata, scope, error } = params; + const { toAddress, amount, assetId } = request.params; + const estimatedChanges = this.#buildEstimatedChanges({ + amount, + assetMetadata, + }); + + await this.#confirmationUIController.renderConfirmationDialog({ + scope, + origin: METAMASK_ORIGIN, + renderContext: { + account, + toAddress, + transactionsFetchStatus: FetchStatus.Error, + errorMessage: getTxnErrorMessageKey(error, account.address), + }, + fee: '', + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + renderOptions: { + loadPrice: false, + securityScanning: false, + localSimulation: false, + }, + initialScan: { + status: 'ERROR', + estimatedChanges, + validation: null, + error: null, + }, + tokenPrices: { + [assetId]: null, + } as ContextWithPrices['tokenPrices'], + }); + } + /** * Builds the estimated balance changes for the send confirmation: a single * outgoing row for the known send asset and amount. The network fee is @@ -385,22 +460,4 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< ], }; } - - /** - * Override the base handler to return invalid when the account is not activated. - * Instead of showing the account not activated alert, it returns an invalid response. - * - * @param _error - The error to handle. - * @param _request - The JSON-RPC request (unused for this handler). - * @returns The invalid response when the account is not activated. - */ - protected override async handleAccountNotActivatedError( - _error: AccountNotActivatedException, - _request: ConfirmSendJsonRpcRequest, - ): Promise { - return { - valid: false, - errors: [{ code: MultiChainSendErrorCodes.Invalid }], - }; - } } diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts index 64be0260a..0cf337abb 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts @@ -223,7 +223,7 @@ describe('OnAmountInputHandler', () => { ); }); - it('passes explicit destination when params.to is set', async () => { + it('uses the sender as destination even when params.to is set', async () => { const { handler, onChainAccount, createValidatedSendTransaction } = setup(); await handler.handle(baseRequest({ to: destinationAddress })); @@ -233,7 +233,7 @@ describe('OnAmountInputHandler', () => { scope, assetId, amount: new BigNumber('10000000'), - destination: destinationAddress, + destination: onChainAccount.accountId, useCache: true, }); }); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts index 72b0e8077..5891734b8 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts @@ -63,7 +63,7 @@ export class OnAmountInputHandler extends BaseClientRequestHandler< * repeated amount checks stay responsive. * * @param resolved - Keyring account, persisted on-chain snapshot, and wallet. - * @param request - JSON-RPC request with `assetId`, `value` (positive amount string), and optional `to` (`scope` is derived from `assetId`). + * @param request - JSON-RPC request with `assetId` and `value` (positive amount string). Optional `to` is ignored; destination validation happens in `confirmSend` (`scope` is derived from `assetId`). * @returns Validation result with `valid` and optional error codes. */ protected async execute( @@ -72,7 +72,7 @@ export class OnAmountInputHandler extends BaseClientRequestHandler< ): Promise { try { const { onChainAccount } = resolved; - const { assetId, value, to, scope } = request.params; + const { assetId, value, scope } = request.params; const { units } = await this.#assetMetadataService.resolve(assetId); const { decimals } = units[0]; @@ -93,8 +93,9 @@ export class OnAmountInputHandler extends BaseClientRequestHandler< scope, assetId, amount: amountInSmallestUnit, - // If no destination is provided, validate a self-transfer to the sender. - destination: to ?? onChainAccount.accountId, + // Always use self account as destination to bypass the error from the destination validation. + // We validate the destination in the send transaction handler. + destination: onChainAccount.accountId, // Use cached network reads so repeated amount checks stay fast. useCache: true, }); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.test.ts new file mode 100644 index 000000000..ec3d898eb --- /dev/null +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.test.ts @@ -0,0 +1,86 @@ +import { USDC_CLASSIC } from '../../services/asset-metadata/__mocks__/assets.fixtures'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverBaseReserveException, + InsufficientBalanceToCoverFeeException, + InvalidAmountForCreateAccountException, + InvalidAssetForCreateAccountException, + RemoveTrustlineWithNonZeroBalanceException, + RequiresMemoException, + TransactionExpireException, + TransactionValidationException, + TrustlineExceedLimitException, + TrustlineNotAuthorizedException, + TrustlineNotFoundException, + UpdateTrustlineException, +} from '../../services/transaction'; +import { getTxnErrorMessageKey } from './utils'; + +const destinationAddress = + 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; +const senderAddress = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4'; +const assetId = USDC_CLASSIC; + +describe('getTxnErrorMessageKey', () => { + it.each([ + { + error: new InsufficientBalanceException('0', '1'), + message: 'confirmation.txnError.insufficientBalance', + }, + { + error: new InsufficientBalanceToCoverFeeException('0', '1'), + message: 'confirmation.txnError.insufficientBalanceToCoverFee', + }, + { + error: new InsufficientBalanceToCoverBaseReserveException('0', '1'), + message: 'confirmation.txnError.insufficientBalanceToCoverBaseReserve', + }, + { + error: new RequiresMemoException(destinationAddress), + message: 'confirmation.txnError.requiresMemo', + }, + { + error: new InvalidAmountForCreateAccountException('0.5'), + message: 'confirmation.txnError.invalidCreateAccountAmount', + }, + { + error: new InvalidAssetForCreateAccountException(assetId), + message: 'confirmation.txnError.invalidCreateAccountAsset', + }, + { + error: new TrustlineNotAuthorizedException(assetId, destinationAddress), + message: 'confirmation.txnError.trustlineNotAuthorized', + }, + { + error: new TrustlineNotFoundException(assetId, destinationAddress), + message: 'confirmation.txnError.trustlineNotFound', + }, + { + error: new TrustlineExceedLimitException(assetId), + message: 'confirmation.txnError.trustlineExceedLimit', + }, + { + error: new RemoveTrustlineWithNonZeroBalanceException('balance'), + message: 'confirmation.txnError.trustlineNonZeroBalance', + }, + { + error: new UpdateTrustlineException('limit'), + message: 'confirmation.txnError.updateTrustlineLimit', + }, + { + error: new TransactionExpireException(0), + message: 'confirmation.txnError.expired', + }, + { + error: new TransactionValidationException('unknown'), + message: 'confirmation.txnError.generic', + }, + { + error: new TrustlineNotFoundException(assetId, senderAddress), + senderAddress, + message: 'confirmation.txnError.trustlineNotFoundOnAccount', + }, + ])('maps $message', ({ error, senderAddress: sender, message }) => { + expect(getTxnErrorMessageKey(error, sender ?? senderAddress)).toBe(message); + }); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts index b4dec26c8..8eed9ec35 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts @@ -1,5 +1,20 @@ -import { TransactionValidationException } from '../../services/transaction'; import type { Transaction } from '../../services/transaction'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverBaseReserveException, + InsufficientBalanceToCoverFeeException, + InvalidAmountForCreateAccountException, + InvalidAssetForCreateAccountException, + RemoveTrustlineWithNonZeroBalanceException, + RequiresMemoException, + TransactionExpireException, + TransactionValidationException, + TrustlineExceedLimitException, + TrustlineNotAuthorizedException, + TrustlineNotFoundException, + UpdateTrustlineException, +} from '../../services/transaction'; +import type { LocalizedMessage } from '../../utils'; /** * Guards the user-approved fee during submit-time transaction refresh. @@ -27,3 +42,59 @@ export function assertRefreshedTransactionFeeNotHigher(params: { ); } } + +/** + * Maps a displayable transaction validation error to its confirmation banner copy. + * + * Shared by send and change-trust confirmations. Unmapped subclasses fall back + * to `confirmation.txnError.generic`. + * + * @param error - The validation error shown in the confirmation banner. + * @param senderAddress - The sender account address, used to tell destination + * vs own-account trustline failures apart. + * @returns The localized message key for the banner subtitle. + */ +export function getTxnErrorMessageKey( + error: TransactionValidationException, + senderAddress: string, +): LocalizedMessage { + if (error instanceof InsufficientBalanceException) { + return 'confirmation.txnError.insufficientBalance'; + } + if (error instanceof InsufficientBalanceToCoverFeeException) { + return 'confirmation.txnError.insufficientBalanceToCoverFee'; + } + if (error instanceof InsufficientBalanceToCoverBaseReserveException) { + return 'confirmation.txnError.insufficientBalanceToCoverBaseReserve'; + } + if (error instanceof RequiresMemoException) { + return 'confirmation.txnError.requiresMemo'; + } + if (error instanceof InvalidAmountForCreateAccountException) { + return 'confirmation.txnError.invalidCreateAccountAmount'; + } + if (error instanceof InvalidAssetForCreateAccountException) { + return 'confirmation.txnError.invalidCreateAccountAsset'; + } + if (error instanceof TrustlineNotAuthorizedException) { + return 'confirmation.txnError.trustlineNotAuthorized'; + } + if (error instanceof TrustlineNotFoundException) { + return error.accountAddress === senderAddress + ? 'confirmation.txnError.trustlineNotFoundOnAccount' + : 'confirmation.txnError.trustlineNotFound'; + } + if (error instanceof TrustlineExceedLimitException) { + return 'confirmation.txnError.trustlineExceedLimit'; + } + if (error instanceof RemoveTrustlineWithNonZeroBalanceException) { + return 'confirmation.txnError.trustlineNonZeroBalance'; + } + if (error instanceof UpdateTrustlineException) { + return 'confirmation.txnError.updateTrustlineLimit'; + } + if (error instanceof TransactionExpireException) { + return 'confirmation.txnError.expired'; + } + return 'confirmation.txnError.generic'; +} diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts index 44a45acde..ec8d1d016 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -36,6 +36,7 @@ import type { SecurityScanRequest, TransactionScanResult, } from '../../services/transaction-scan'; +import type { LocalizedMessage } from '../../utils'; export type FeeData = { assetId: KnownCaip19AssetIdOrSlip44Id; @@ -137,6 +138,9 @@ export type ConfirmationBaseProps = Partial & { networkImage: string | null; origin: string; feeData?: FeeData; + // Locale key for the validation banner subtitle. When omitted, the banner + // falls back to `confirmation.txnError.generic`. + errorMessage?: LocalizedMessage; // Identifies the active view so shared event handlers (e.g. the malicious // acknowledgement screen) can re-render the correct confirmation. interfaceKey?: ConfirmationInterfaceKey; diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.test.tsx index f7df36547..52a65876a 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.test.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.test.tsx @@ -1,3 +1,5 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; + import { defaultPreferences as preferences, getProps, @@ -19,7 +21,7 @@ describe('ConfirmationAlerts', () => { expect(getType(component)).toBe('Banner'); expect(getProps(component)).toMatchObject({ severity: 'danger', - title: 'Transaction is no longer valid', + title: 'This transaction is expected to fail.', }); }); @@ -52,6 +54,25 @@ describe('ConfirmationAlerts', () => { expect(component).toBeNull(); }); + it('renders the validation banner with pre-submit send error copy', () => { + const component = ConfirmationAlerts({ + preferences, + scan: null, + scanFetchStatus: FetchStatus.Fetched, + transactionsFetchStatus: FetchStatus.Error, + errorMessage: 'confirmation.txnError.requiresMemo', + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'danger', + title: 'This transaction is expected to fail.', + }); + expect( + getProps(getProps(component)?.children as ComponentOrElement)?.children, + ).toBe('This account requires a memo. Sends to it are not supported.'); + }); + it('shows the validation banner (not the scan banner) when both would apply', () => { const component = ConfirmationAlerts({ preferences, @@ -61,7 +82,7 @@ describe('ConfirmationAlerts', () => { }); expect(getProps(component)).toMatchObject({ - title: 'Transaction is no longer valid', + title: 'This transaction is expected to fail.', }); }); }); diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.tsx index 3538fd65c..d4a45b9dd 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.tsx @@ -10,6 +10,7 @@ type ConfirmationAlertsProps = { scan: ConfirmationBaseProps['scan']; scanFetchStatus: FetchStatus; transactionsFetchStatus: FetchStatus; + errorMessage?: ConfirmationBaseProps['errorMessage']; }; /** @@ -24,6 +25,7 @@ type ConfirmationAlertsProps = { * @param props.scan - Latest transaction scan result. * @param props.scanFetchStatus - Latest transaction scan fetch status. * @param props.transactionsFetchStatus - Latest transaction re-validation fetch status. + * @param props.errorMessage - Optional locale key for the validation banner subtitle. * @returns The banner to render, or `null` when none applies. */ export const ConfirmationAlerts = ({ @@ -31,6 +33,7 @@ export const ConfirmationAlerts = ({ scan, scanFetchStatus, transactionsFetchStatus, + errorMessage, }: ConfirmationAlertsProps): ComponentOrElement | null => { switch (resolveConfirmationBanner({ preferences, transactionsFetchStatus })) { case ConfirmationBanner.TransactionValidation: @@ -38,6 +41,7 @@ export const ConfirmationAlerts = ({ ); case ConfirmationBanner.TransactionScan: diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionValidationAlert.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionValidationAlert.tsx index 86d70886c..1220a7c7e 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionValidationAlert.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionValidationAlert.tsx @@ -8,28 +8,28 @@ import { FetchStatus } from '../api'; type TransactionValidationAlertProps = { preferences: ConfirmationBaseProps['preferences']; transactionsFetchStatus: FetchStatus; + errorMessage?: ConfirmationBaseProps['errorMessage']; }; -// Danger banner shown when background re-validation finds the pending transaction -// is no longer valid (expired, sequence changed, or insufficient balance). +// Danger banner shown when the pending transaction is invalid: either +// pre-submit validation failed, or background re-validation found the +// transaction can no longer be submitted (expired, sequence, or balance). export const TransactionValidationAlert = ({ preferences, transactionsFetchStatus, + errorMessage = 'confirmation.txnError.generic', }: TransactionValidationAlertProps): ComponentOrElement | null => { if (transactionsFetchStatus !== FetchStatus.Error) { return null; } const translate = i18n(preferences.locale); + const title = translate('confirmation.simulationErrorTitle'); + const subtitle = translate(errorMessage); return ( - - - {translate('confirmation.transactionInvalidSubtitle')} - + + {subtitle} ); }; diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx index 27c40d9aa..e9ec81ff5 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx @@ -56,6 +56,7 @@ export const ConfirmSendTransaction = ({ scan, scanFetchStatus = FetchStatus.Initial, transactionsFetchStatus = FetchStatus.Initial, + errorMessage, }: ConfirmSendTransactionProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; @@ -72,6 +73,7 @@ export const ConfirmSendTransaction = ({ scan={scan} scanFetchStatus={scanFetchStatus} transactionsFetchStatus={transactionsFetchStatus} + errorMessage={errorMessage} /> {null} @@ -139,12 +141,14 @@ export const ConfirmSendTransaction = ({ /> {null} {/* Fee Breakdown */} - + {Object.keys(feeData).length === 0 ? null : ( + + )} { const t = i18n(locale); const { address } = account; @@ -74,6 +75,7 @@ export const ConfirmSignChangeTrustOptIn = ({ scan={scan} scanFetchStatus={scanFetchStatus} transactionsFetchStatus={transactionsFetchStatus} + errorMessage={errorMessage} /> {null} @@ -137,12 +139,14 @@ export const ConfirmSignChangeTrustOptIn = ({ /> {null} {/* Fee Breakdown */} - + {Object.keys(feeData).length === 0 ? null : ( + + )} { const t = i18n(locale); const { address } = account; @@ -74,6 +75,7 @@ export const ConfirmSignChangeTrustOptOut = ({ scan={scan} scanFetchStatus={scanFetchStatus} transactionsFetchStatus={transactionsFetchStatus} + errorMessage={errorMessage} /> {null} @@ -137,12 +139,14 @@ export const ConfirmSignChangeTrustOptOut = ({ /> {null} {/* Fee Breakdown */} - + {Object.keys(feeData).length === 0 ? null : ( + + )}