UNPKG

@ledgerhq/coin-stellar

Version:
237 lines (209 loc) 7.94 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import { TransactionValidation, TransactionIntent, FeeEstimation, Balance, } from '@ledgerhq/coin-module-framework/api/types' import { formatCurrencyUnit } from '@ledgerhq/coin-module-framework/currencies/index' import { AmountRequired, InvalidAddress, InvalidAddressBecauseDestinationIsAlsoSource, NotEnoughBalance, NotEnoughBalanceBecauseDestinationNotCreated, NotEnoughSpendableBalance, RecipientRequired, } from '@ledgerhq/errors' import BigNumber from 'bignumber.js' import { fetchAccountNetworkInfo, getRecipientAccount } from '../network' import { fetchAccount } from '../network/horizon' import { BASE_RESERVE, MIN_BALANCE } from '../network/serialization' import { StellarAssetNotAccepted, StellarAssetNotFound, StellarAssetRequired, StellarFeeSmallerThanBase, StellarFeeSmallerThanRecommended, StellarMemo, StellarMuxedAccountNotExist, StellarNotEnoughNativeBalance, StellarNotEnoughNativeBalanceToAddTrustline, StellarSourceHasMultiSign, StellarWrongMemoFormat, } from '../types' import { isAccountMultiSign, isAddressValid } from './utils' import { validateMemo } from './validateMemo' export const validateIntent = async ( transactionIntent: TransactionIntent<StellarMemo>, balances: Balance[], customFees?: FeeEstimation ): Promise<TransactionValidation> => { const errors: Record<string, Error> = {} const warnings: Record<string, Error> = {} const useAllAmount = !!transactionIntent.useAllAmount const destinationNotExistMessage = new NotEnoughBalanceBecauseDestinationNotCreated('', { minimalAmount: `${MIN_BALANCE} XLM`, }) const { spendableBalance, balance } = await fetchAccount(transactionIntent.sender) const networkInfo = await fetchAccountNetworkInfo(transactionIntent.sender) const estimatedFees = customFees?.value ?? 0n const baseReserve = networkInfo.baseReserve ? BigInt(Math.round(networkInfo.baseReserve.toNumber() * 10)) / 10n : 0n const isAssetPayment = transactionIntent.asset.type !== 'native' const nativeBalance = BigInt(balance.toString()) const nativeAmountAvailable = BigInt(spendableBalance.toString()) - estimatedFees let amount = 0n let maxAmount = 0n let totalSpent = 0n // Enough native balance to cover transaction (with required reserve + fees) if (!errors.amount && nativeAmountAvailable < 0) { errors.amount = new StellarNotEnoughNativeBalance() } const networkInfoBaseFee = BigInt(networkInfo.baseFee.toString() || '0') const networkFees = BigInt(networkInfo.fees.toString() || '0') // Entered fee is smaller than base fee if (estimatedFees < networkInfoBaseFee) { errors.transaction = new StellarFeeSmallerThanBase() // Entered fee is smaller than recommended } else if (estimatedFees < networkFees) { warnings.transaction = new StellarFeeSmallerThanRecommended() } // Operation specific checks if (transactionIntent.type === 'changeTrust') { // Check asset provided if ( transactionIntent.asset.type !== 'native' && (('assetReference' in transactionIntent.asset && !transactionIntent.asset.assetReference) || ('assetOwner' in transactionIntent.asset && !transactionIntent.asset.assetOwner)) ) { // This is unlikely errors.transaction = new StellarAssetRequired('') } // Has enough native balance to add new trustline // NOTE: need to do this as BASE_RESERVE is 0.5 const SCALE = 10n const scaledNative = nativeAmountAvailable * SCALE const scaledBaseReserve = BigInt(BASE_RESERVE * 10) // = 5n if (scaledNative - scaledBaseReserve < 0n) { errors.amount = new StellarNotEnoughNativeBalanceToAddTrustline() } } else { // Payment // Check recipient address if (!transactionIntent.recipient) { errors.recipient = new RecipientRequired('') } else if (!isAddressValid(transactionIntent.recipient)) { errors.recipient = new InvalidAddress('', { currencyName: transactionIntent.asset.name ?? '', // NOTE: before account.currencyName, }) } else if (transactionIntent.sender === transactionIntent.recipient) { errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource() } const recipientAccount = await getRecipientAccount({ recipient: transactionIntent.recipient, }) // Check recipient account if (!recipientAccount?.id && !errors.recipient && !warnings.recipient) { if (recipientAccount?.isMuxedAccount) { errors.recipient = new StellarMuxedAccountNotExist() } else { if (isAssetPayment) { errors.recipient = destinationNotExistMessage } else { warnings.recipient = destinationNotExistMessage } } } // Asset payment if (isAssetPayment) { const asset = transactionIntent.asset if (asset.type === 'native' || (!('assetReference' in asset) && !('assetOwner' in asset))) { throw new StellarAssetNotFound() } // Check recipient account accepts asset if ( recipientAccount?.id && !errors.recipient && !warnings.recipient && !recipientAccount.assetIds.includes(`${asset.assetReference}:${asset.assetOwner}`) ) { errors.recipient = new StellarAssetNotAccepted('', { assetCode: asset.assetReference, }) } const assetBalance = balances.find( (b) => b.asset.type !== 'native' && 'assetReference' in b.asset && b.asset.assetReference === asset.assetReference && 'assetOwner' in b.asset && b.asset.assetOwner === asset.assetOwner ) if (!assetBalance) { // This is unlikely throw new StellarAssetNotFound() } const assetSpendableBalance = assetBalance.value - (assetBalance?.locked || 0n) maxAmount = assetSpendableBalance || assetBalance.value amount = useAllAmount ? maxAmount : transactionIntent.amount totalSpent = amount if (!errors.amount && amount > assetBalance.value) { errors.amount = new NotEnoughBalance() } } else { // Native payment maxAmount = nativeAmountAvailable amount = useAllAmount ? maxAmount : (transactionIntent.amount ?? 0n) if (amount > maxAmount) { errors.amount = new NotEnoughBalance() } totalSpent = useAllAmount ? nativeAmountAvailable : amount + estimatedFees // Need to send at least 1 XLM to create an account if (!errors.recipient && !recipientAccount?.id && !errors.amount && amount < 10000000n) { errors.amount = destinationNotExistMessage } if (totalSpent > nativeBalance - baseReserve) { errors.amount = new NotEnoughSpendableBalance(undefined, { minimumAmount: transactionIntent.asset.unit ? formatCurrencyUnit( transactionIntent.asset.unit, new BigNumber(baseReserve.toString()), { disableRounding: true, showCode: true, } ) : 'Unknown unit', }) } if (!errors.recipient && !errors.amount && (amount < 0n || totalSpent > nativeBalance)) { errors.amount = new NotEnoughBalance() totalSpent = 0n amount = 0n } } if (amount === 0n) { errors.amount = new AmountRequired() } } if (await isAccountMultiSign(transactionIntent.sender)) { errors.recipient = new StellarSourceHasMultiSign() } if ( transactionIntent?.memo?.type !== 'NO_MEMO' && !validateMemo(transactionIntent?.memo?.value, transactionIntent?.memo?.type) ) { errors.transaction = new StellarWrongMemoFormat() } return { errors, warnings, estimatedFees, amount, totalSpent, } } export default validateIntent