From 1ba6d6d67d49ae41f588178e641dfb46066c8e78 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Mon, 27 Jul 2026 10:42:08 +0200 Subject: [PATCH 1/2] feat: add validation error struct for computeFee to handle native balance errors --- packages/snap/snap.manifest.json | 2 +- .../handlers/clientRequest/computeFee.test.ts | 73 ++++++++++--------- .../src/handlers/clientRequest/computeFee.ts | 28 ++++--- .../transaction/TransactionService.ts | 49 ++++++++++--- 4 files changed, 96 insertions(+), 56 deletions(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index d3e13034..5a9807ee 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": "jNQ7xiK1ZDhNLMOBSpTnPkdsRJ0/oeGMzI3TOxGwZEA=", + "shasum": "+iYAEASojq9NeVae5z93xzekwO+objvkJtJn86kQxk4=", "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..3cacc739 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,50 @@ 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', + }); }); - 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', + }); }); 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..2d2e7816 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, @@ -19,6 +20,7 @@ import { NATIVE_ASSET_SYMBOL } from '../../constants'; import { InsufficientBalanceException, InsufficientBalanceToCoverFeeException, + TransactionValidationException, } from '../../services/transaction'; import type { TransactionService } from '../../services/transaction/TransactionService'; import { isSlip44Id } from '../../utils'; @@ -104,17 +106,23 @@ 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; + + 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)), }, - ]; + }); } throw error; } 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; } From e0c58b1d4bf2d76f191bc94c3dab3d4209fc85d9 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Mon, 27 Jul 2026 12:15:03 +0200 Subject: [PATCH 2/2] feat: add reserveAmount in the error struct --- packages/snap/snap.manifest.json | 2 +- .../handlers/clientRequest/computeFee.test.ts | 2 ++ .../src/handlers/clientRequest/computeFee.ts | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 5a9807ee..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": "+iYAEASojq9NeVae5z93xzekwO+objvkJtJn86kQxk4=", + "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 3cacc739..d2444ecb 100644 --- a/packages/snap/src/handlers/clientRequest/computeFee.test.ts +++ b/packages/snap/src/handlers/clientRequest/computeFee.test.ts @@ -204,6 +204,7 @@ describe('ComputeFeeHandler', () => { assetId: KnownCaip19Slip44IdMap[scope], availableAmount: '0.00001', requiredAmount: '1.25', + reserveAmount: '1', }); }); @@ -229,6 +230,7 @@ describe('ComputeFeeHandler', () => { assetId: KnownCaip19Slip44IdMap[scope], availableAmount: '0.00001', requiredAmount: '5', + reserveAmount: '1', }); }); diff --git a/packages/snap/src/handlers/clientRequest/computeFee.ts b/packages/snap/src/handlers/clientRequest/computeFee.ts index 2d2e7816..ac710ac7 100644 --- a/packages/snap/src/handlers/clientRequest/computeFee.ts +++ b/packages/snap/src/handlers/clientRequest/computeFee.ts @@ -17,6 +17,8 @@ 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, @@ -111,6 +113,8 @@ export class ComputeFeeHandler extends BaseClientRequestHandler< ? MultiChainSendErrorCodes.InsufficientBalance : MultiChainSendErrorCodes.InsufficientBalanceToCoverFee; + const reserveAmount = getReserveDisplayAmount(onChainAccount); + throw new TransactionValidationException(error.message, { cause: error, data: { @@ -121,6 +125,7 @@ export class ComputeFeeHandler extends BaseClientRequestHandler< : KnownCaip19Slip44IdMap[scope], availableAmount: toDisplayBalance(new BigNumber(error.balance)), requiredAmount: toDisplayBalance(new BigNumber(error.required)), + ...(reserveAmount === undefined ? {} : { reserveAmount }), }, }); } @@ -128,3 +133,25 @@ export class ComputeFeeHandler extends BaseClientRequestHandler< } } } + +/** + * 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; + } +}