Changeset View
Changeset View
Standalone View
Standalone View
apps/marlin-wallet/web/src/amount.ts
| // Copyright (c) 2025 The Bitcoin developers | // Copyright (c) 2025 The Bitcoin developers | ||||||||||||||||||||||
| // Distributed under the MIT software license, see the accompanying | // Distributed under the MIT software license, see the accompanying | ||||||||||||||||||||||
| // file COPYING or http://www.opensource.org/licenses/mit-license.php. | // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||||||||||||||||||||||
| import { webViewError } from './common'; | import { webViewError } from './common'; | ||||||||||||||||||||||
| import { ChronikClient } from 'chronik-client'; | import { ChronikClient } from 'chronik-client'; | ||||||||||||||||||||||
| import { Wallet } from 'ecash-wallet'; | import { Wallet } from 'ecash-wallet'; | ||||||||||||||||||||||
| import { buildAction } from './wallet'; | import { activeAssetDecimals } from './active-asset'; | ||||||||||||||||||||||
| import { buildAction, buildTokenSendAction } from './wallet'; | |||||||||||||||||||||||
| import { XEC_ASSET, type AssetDefinition } from './supported-assets'; | |||||||||||||||||||||||
| // Conversion function for display | export function atomsToUnit(atoms: number, decimals: number): number { | ||||||||||||||||||||||
bytesofman: This (probably) is fine for FIRMA and XECX, with FIRMA having 4 decimal places and a modest-ish… | |||||||||||||||||||||||
FabienAuthorUnsubmitted Done Inline ActionsI took a shortcut and added an assertion that it fits a safe number range, same below. This makes it work to up to 900 billion Firma which should buy me enough time to update if needed. Fabien: I took a shortcut and added an assertion that it fits a safe number range, same below. This… | |||||||||||||||||||||||
| export function satsToXec(sats: number): number { | return Math.round(atoms) / Math.pow(10, decimals); | ||||||||||||||||||||||
| return Math.round(sats) / 100; // Round to avoid floating-point errors | |||||||||||||||||||||||
| } | } | ||||||||||||||||||||||
| // Calculate transaction amount for our address | export function unitToAtoms(unit: number, decimals: number): number { | ||||||||||||||||||||||
bytesofmanUnsubmitted Not Done Inline Actionssame issue bytesofman: same issue | |||||||||||||||||||||||
| export async function calculateTransactionAmountSats( | return Math.round(unit * Math.pow(10, decimals)); | ||||||||||||||||||||||
| } | |||||||||||||||||||||||
| /** | |||||||||||||||||||||||
| * Net change in atoms for this wallet in a tx (satoshis for XEC, token atoms | |||||||||||||||||||||||
| * when tokenId is set). | |||||||||||||||||||||||
| */ | |||||||||||||||||||||||
| export async function calculateTransactionAmountAtoms( | |||||||||||||||||||||||
| wallet: Wallet, | wallet: Wallet, | ||||||||||||||||||||||
| chronik: ChronikClient, | chronik: ChronikClient, | ||||||||||||||||||||||
| txid: string, | txid: string, | ||||||||||||||||||||||
| tokenId: string | null, | |||||||||||||||||||||||
| ): Promise<number> { | ): Promise<number> { | ||||||||||||||||||||||
| try { | try { | ||||||||||||||||||||||
| // Get transaction details from Chronik | // Get transaction details from Chronik | ||||||||||||||||||||||
| const tx = await chronik.tx(txid); | const tx = await chronik.tx(txid); | ||||||||||||||||||||||
| if (!tx) { | if (!tx) { | ||||||||||||||||||||||
| webViewError('Transaction not found:', txid); | webViewError('Transaction not found:', txid); | ||||||||||||||||||||||
| return 0; | return 0; | ||||||||||||||||||||||
| } | } | ||||||||||||||||||||||
| return Number(wallet.getTxAmounts(tx).balanceSatsDelta); | const { balanceSatsDelta, tokenDeltas } = wallet.getTxAmounts(tx); | ||||||||||||||||||||||
| if (tokenId === null) { | |||||||||||||||||||||||
| return Number(balanceSatsDelta); | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
| const delta = tokenDeltas.get(tokenId); | |||||||||||||||||||||||
| return delta !== undefined ? Number(delta) : 0; | |||||||||||||||||||||||
| } catch (error) { | } catch (error) { | ||||||||||||||||||||||
| webViewError('Failed calculating transaction amount:', error); | webViewError('Failed calculating transaction amount:', error); | ||||||||||||||||||||||
| return 0; | return 0; | ||||||||||||||||||||||
| } | } | ||||||||||||||||||||||
| } | } | ||||||||||||||||||||||
| // Calculate maximum spendable amount | // Calculate maximum spendable XEC amount | ||||||||||||||||||||||
| export function calculateMaxSpendableAmount(wallet: Wallet): number { | export function calculateMaxSpendableAmount(wallet: Wallet): number { | ||||||||||||||||||||||
| return satsToXec(Number(wallet.maxSendSats())); | return atomsToUnit(Number(wallet.maxSendSats()), XEC_ASSET.decimals); | ||||||||||||||||||||||
| } | |||||||||||||||||||||||
| export function calculateMaxSpendableTokenDisplay( | |||||||||||||||||||||||
| wallet: Wallet, | |||||||||||||||||||||||
| tokenId: string, | |||||||||||||||||||||||
| ): number { | |||||||||||||||||||||||
| const atoms = wallet | |||||||||||||||||||||||
| .spendableUtxos() | |||||||||||||||||||||||
| .filter( | |||||||||||||||||||||||
| utxo => utxo.token?.tokenId === tokenId && !utxo.token.isMintBaton, | |||||||||||||||||||||||
| ) | |||||||||||||||||||||||
| .reduce((sum, utxo) => sum + (utxo.token?.atoms ?? 0n), 0n); | |||||||||||||||||||||||
| return atomsToUnit(Number(atoms), activeAssetDecimals()); | |||||||||||||||||||||||
| } | } | ||||||||||||||||||||||
| // Estimate transaction fee | |||||||||||||||||||||||
| export function estimateTransactionFee( | export function estimateTransactionFee( | ||||||||||||||||||||||
| wallet: Wallet, | wallet: Wallet, | ||||||||||||||||||||||
| recipientAddress: string, | recipientAddress: string, | ||||||||||||||||||||||
| amountXEC: number, | amountXEC: number, | ||||||||||||||||||||||
| opReturnRaw?: string, | opReturnRaw?: string, | ||||||||||||||||||||||
| ): { feeXEC: number; totalXEC: number } | null { | ): { feeXEC: number; totalXEC: number } | null { | ||||||||||||||||||||||
| try { | try { | ||||||||||||||||||||||
| // Convert XEC to satoshis (1 XEC = 100 satoshis) | const amountSatoshis = unitToAtoms(amountXEC, XEC_ASSET.decimals); | ||||||||||||||||||||||
| const amountSatoshis = Math.round(amountXEC * 100); | |||||||||||||||||||||||
| // Build the transaction to get fee estimate | |||||||||||||||||||||||
| const action = buildAction( | const action = buildAction( | ||||||||||||||||||||||
| wallet, | wallet, | ||||||||||||||||||||||
| recipientAddress, | recipientAddress, | ||||||||||||||||||||||
| amountSatoshis, | amountSatoshis, | ||||||||||||||||||||||
| opReturnRaw, | opReturnRaw, | ||||||||||||||||||||||
| ); | ); | ||||||||||||||||||||||
| const inspectAction = action.inspect(); | const inspectAction = action.inspect(); | ||||||||||||||||||||||
| // Get fee in satoshis and convert to XEC | |||||||||||||||||||||||
| const feeSatoshis = Number(inspectAction.fee()); | const feeSatoshis = Number(inspectAction.fee()); | ||||||||||||||||||||||
| const feeXEC = satsToXec(feeSatoshis); | const feeXEC = atomsToUnit(feeSatoshis, XEC_ASSET.decimals); | ||||||||||||||||||||||
| const totalXEC = amountXEC + feeXEC; | const totalXEC = amountXEC + feeXEC; | ||||||||||||||||||||||
| return { feeXEC, totalXEC }; | return { feeXEC, totalXEC }; | ||||||||||||||||||||||
| } catch (error) { | } catch (error) { | ||||||||||||||||||||||
| webViewError('Failed estimating transaction fee:', error); | webViewError('Failed estimating transaction fee:', error); | ||||||||||||||||||||||
| return null; | return null; | ||||||||||||||||||||||
| } | } | ||||||||||||||||||||||
| } | } | ||||||||||||||||||||||
| export function estimateTokenSendFee( | |||||||||||||||||||||||
| wallet: Wallet, | |||||||||||||||||||||||
| recipientAddress: string, | |||||||||||||||||||||||
| amountUnit: number, | |||||||||||||||||||||||
| token: AssetDefinition, | |||||||||||||||||||||||
| ): { feeXEC: number } | null { | |||||||||||||||||||||||
| try { | |||||||||||||||||||||||
| const atoms = BigInt(unitToAtoms(amountUnit, token.decimals)); | |||||||||||||||||||||||
| const action = buildTokenSendAction( | |||||||||||||||||||||||
| wallet, | |||||||||||||||||||||||
| recipientAddress, | |||||||||||||||||||||||
| atoms, | |||||||||||||||||||||||
| token, | |||||||||||||||||||||||
| ); | |||||||||||||||||||||||
| const inspectAction = action.inspect(); | |||||||||||||||||||||||
| let inputSats = 0n; | |||||||||||||||||||||||
| let walletOutputSats = 0n; | |||||||||||||||||||||||
| for (const builtTx of inspectAction.txs) { | |||||||||||||||||||||||
| for (const input of builtTx.tx.inputs) { | |||||||||||||||||||||||
| inputSats += input.signData?.sats ?? 0n; | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
| for (const out of builtTx.tx.outputs) { | |||||||||||||||||||||||
| if (wallet.isWalletScript(out.script)) { | |||||||||||||||||||||||
| walletOutputSats += out.sats; | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
| const feeSatoshis = inputSats - walletOutputSats; | |||||||||||||||||||||||
bytesofmanUnsubmitted Done Inline Actions
ecash-wallet exposes a fee() method on InspectAction, the class of inspectAction bytesofman: ecash-wallet exposes a `fee()` method on `InspectAction`, the class of `inspectAction` | |||||||||||||||||||||||
FabienAuthorUnsubmitted Done Inline Actionsyeah I wrote this function and even used it for XEC fee estimation in the function directly above... I just forgot about it Fabien: yeah I wrote this function and even used it for XEC fee estimation in the function directly… | |||||||||||||||||||||||
FabienAuthorUnsubmitted Done Inline ActionsWhen writing the tests I understood: it's done that way on purpose because I want to show gas + fees and not only the fees. I keep the UI showing "fee" only as I think it's simply easier to understand that way Fabien: When writing the tests I understood: it's done that way on purpose because I want to show gas +… | |||||||||||||||||||||||
| return { feeXEC: atomsToUnit(Number(feeSatoshis), XEC_ASSET.decimals) }; | |||||||||||||||||||||||
| } catch (error) { | |||||||||||||||||||||||
| webViewError('Failed estimating token transaction fee:', error); | |||||||||||||||||||||||
| return null; | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
This (probably) is fine for FIRMA and XECX, with FIRMA having 4 decimal places and a modest-ish supply.
However it should probably still be tested to show that no accuracy is lost for very small or very large numbers.
This approach would not work for tokens with 9 decimal places and a large supply.
Cashtab uses string methods; it's also possible to generalize with bignumber.js