Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 39 additions & 36 deletions packages/snap/src/handlers/clientRequest/computeFee.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,6 +22,7 @@ import {
InsufficientBalanceException,
InsufficientBalanceToCoverFeeException,
TransactionService,
TransactionValidationException,
} from '../../services/transaction';
import {
buildMockInvokeHostFunctionTransaction,
Expand Down Expand Up @@ -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 () => {
Expand Down
55 changes: 45 additions & 10 deletions packages/snap/src/handlers/clientRequest/computeFee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import {
ComputeFeeJsonRpcRequestStruct,
ComputeFeeJsonRpcResponseStruct,
MultiChainSendErrorCodes,
} from './api';
import type {
AccountResolver,
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
}
49 changes: 40 additions & 9 deletions packages/snap/src/services/transaction/TransactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading