diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 97b74c41..dd6e9cc2 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "1ftcq5ewpbXIRZpVifhbpo4kHtdvXB4MEoo+bgjMWGQ=", + "shasum": "RqbkugCNiav9qiJcb1QMyvL0xadFNLVLszDGTBpCrfM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/handlers/clientRequest/computeFee.test.ts b/packages/snap/src/handlers/clientRequest/computeFee.test.ts index a5e9b6fb..d2444ecb 100644 --- a/packages/snap/src/handlers/clientRequest/computeFee.test.ts +++ b/packages/snap/src/handlers/clientRequest/computeFee.test.ts @@ -1,7 +1,7 @@ import { FeeType } from '@metamask/keyring-api'; import { Networks } from '@stellar/stellar-sdk'; -import { ClientRequestMethod } from './api'; +import { ClientRequestMethod, MultiChainSendErrorCodes } from './api'; import type { ComputeFeeJsonRpcRequest } from './api'; import { ComputeFeeHandler } from './computeFee'; import { KnownCaip19Slip44IdMap, KnownCaip2ChainId } from '../../api'; @@ -22,6 +22,7 @@ import { InsufficientBalanceException, InsufficientBalanceToCoverFeeException, TransactionService, + TransactionValidationException, } from '../../services/transaction'; import { buildMockInvokeHostFunctionTransaction, @@ -185,50 +186,52 @@ describe('ComputeFeeHandler', () => { ); }); - it('returns the required native fee when balance is insufficient to cover fees', async () => { + it('throws a structured validation error when balance is insufficient to cover fees', async () => { const { handler, request, createValidatedSwapTransaction } = setup(); - createValidatedSwapTransaction.mockRejectedValueOnce( - new InsufficientBalanceToCoverFeeException('100', '12500000'), - ); + const cause = new InsufficientBalanceToCoverFeeException('100', '12500000'); + createValidatedSwapTransaction.mockRejectedValueOnce(cause); - const result = await handler.handle(request); + const error: unknown = await handler + .handle(request) + .catch((thrown: unknown) => thrown); - expect(result).toStrictEqual([ - { - type: FeeType.Base, - asset: { - unit: NATIVE_ASSET_SYMBOL, - type: KnownCaip19Slip44IdMap[scope], - amount: '1.25', - fungible: true, - }, - }, - ]); + expect(error).toBeInstanceOf(TransactionValidationException); + const validationError = error as TransactionValidationException; + expect(validationError.message).toBe(cause.message); + expect(validationError.cause).toBe(cause); + expect(validationError.data).toStrictEqual({ + code: MultiChainSendErrorCodes.InsufficientBalanceToCoverFee, + assetId: KnownCaip19Slip44IdMap[scope], + availableAmount: '0.00001', + requiredAmount: '1.25', + reserveAmount: '1', + }); }); - it('returns the required native fee when native balance is insufficient for the swap', async () => { + it('throws a structured validation error when native balance is insufficient for the swap', async () => { const { handler, request, createValidatedSwapTransaction } = setup(); - createValidatedSwapTransaction.mockRejectedValueOnce( - new InsufficientBalanceException( - '100', - '50000000', - KnownCaip19Slip44IdMap[scope], - ), + const cause = new InsufficientBalanceException( + '100', + '50000000', + KnownCaip19Slip44IdMap[scope], ); + createValidatedSwapTransaction.mockRejectedValueOnce(cause); - const result = await handler.handle(request); + const error: unknown = await handler + .handle(request) + .catch((thrown: unknown) => thrown); - expect(result).toStrictEqual([ - { - type: FeeType.Base, - asset: { - unit: NATIVE_ASSET_SYMBOL, - type: KnownCaip19Slip44IdMap[scope], - amount: '5', - fungible: true, - }, - }, - ]); + expect(error).toBeInstanceOf(TransactionValidationException); + const validationError = error as TransactionValidationException; + expect(validationError.message).toBe(cause.message); + expect(validationError.cause).toBe(cause); + expect(validationError.data).toStrictEqual({ + code: MultiChainSendErrorCodes.InsufficientBalance, + assetId: KnownCaip19Slip44IdMap[scope], + availableAmount: '0.00001', + requiredAmount: '5', + reserveAmount: '1', + }); }); it('rethrows when balance is insufficient for a non-native asset', async () => { diff --git a/packages/snap/src/handlers/clientRequest/computeFee.ts b/packages/snap/src/handlers/clientRequest/computeFee.ts index 81b5d23b..ac710ac7 100644 --- a/packages/snap/src/handlers/clientRequest/computeFee.ts +++ b/packages/snap/src/handlers/clientRequest/computeFee.ts @@ -8,6 +8,7 @@ import type { import { ComputeFeeJsonRpcRequestStruct, ComputeFeeJsonRpcResponseStruct, + MultiChainSendErrorCodes, } from './api'; import type { AccountResolver, @@ -16,9 +17,12 @@ import type { import { BaseClientRequestHandler } from './base'; import { KnownCaip19Slip44IdMap } from '../../api'; import { NATIVE_ASSET_SYMBOL } from '../../constants'; +import type { OnChainAccount } from '../../services/on-chain-account'; +import { minimumBalanceStroops } from '../../services/on-chain-account/utils'; import { InsufficientBalanceException, InsufficientBalanceToCoverFeeException, + TransactionValidationException, } from '../../services/transaction'; import type { TransactionService } from '../../services/transaction/TransactionService'; import { isSlip44Id } from '../../utils'; @@ -104,19 +108,50 @@ export class ComputeFeeHandler extends BaseClientRequestHandler< isSlip44Id(error.assetId)) || error instanceof InsufficientBalanceToCoverFeeException ) { - return [ - { - type: FeeType.Base, - asset: { - unit: NATIVE_ASSET_SYMBOL, - type: KnownCaip19Slip44IdMap[scope], - amount: toDisplayBalance(new BigNumber(error.required)), - fungible: true as const, - }, + const code = + error instanceof InsufficientBalanceException + ? MultiChainSendErrorCodes.InsufficientBalance + : MultiChainSendErrorCodes.InsufficientBalanceToCoverFee; + + const reserveAmount = getReserveDisplayAmount(onChainAccount); + + throw new TransactionValidationException(error.message, { + cause: error, + data: { + code, + assetId: + error instanceof InsufficientBalanceException + ? error.assetId + : KnownCaip19Slip44IdMap[scope], + availableAmount: toDisplayBalance(new BigNumber(error.balance)), + requiredAmount: toDisplayBalance(new BigNumber(error.required)), + ...(reserveAmount === undefined ? {} : { reserveAmount }), }, - ]; + }); } throw error; } } } + +/** + * Native (XLM) minimum reserve for the account, in display units. + * + * @param onChainAccount Resolved on-chain account. + * @returns Reserve in display units, or `undefined` when ledger meta is not bound. + */ +function getReserveDisplayAmount( + onChainAccount: OnChainAccount, +): string | undefined { + try { + return toDisplayBalance( + minimumBalanceStroops({ + subentryCount: onChainAccount.subentryCount, + numSponsoring: onChainAccount.numSponsoring, + numSponsored: onChainAccount.numSponsored, + }), + ); + } catch { + return undefined; + } +} diff --git a/packages/snap/src/services/transaction/TransactionService.ts b/packages/snap/src/services/transaction/TransactionService.ts index 0a7ec424..a5a39029 100644 --- a/packages/snap/src/services/transaction/TransactionService.ts +++ b/packages/snap/src/services/transaction/TransactionService.ts @@ -401,15 +401,46 @@ export class TransactionService { onChainAccount, ); - this.validateTransaction(transaction, onChainAccount, { - expectedOPTypes: [ - SupportedOperations.Payment, - SupportedOperations.PathPayment, - SupportedOperations.InvokeHostFunction, - SupportedOperations.ChangeTrust, - ], - preloadedAccounts, - }); + try { + this.validateTransaction(transaction, onChainAccount, { + expectedOPTypes: [ + SupportedOperations.Payment, + SupportedOperations.PathPayment, + SupportedOperations.InvokeHostFunction, + SupportedOperations.ChangeTrust, + ], + preloadedAccounts, + }); + } catch (error) { + if ( + error instanceof InsufficientBalanceException && + isSlip44Id(error.assetId) + ) { + // Swap envelopes can split the source amount across multiple native + // debits. Report the amount available before any operation is applied + // instead of the remainder at whichever debit failed. + const availableBalance = onChainAccount.nativeSpendableBalance.minus( + transaction.totalFee, + ); + + // The simulator applies the fee first, then debits sequentially, so + // `error.balance` is the spendable remainder when the debit failed. + // Debits already applied = availableBalance - error.balance; adding + // the failing debit yields the total native the envelope needs up to + // the failure point, keeping `required` consistent with `balance`. + const requiredBalance = availableBalance + .minus(error.balance) + .plus(error.required); + + throw new InsufficientBalanceException( + availableBalance.toString(), + requiredBalance.toString(), + error.assetId, + ); + } + + throw error; + } return transaction; }