Changeset View
Changeset View
Standalone View
Standalone View
apps/marlin-wallet/web/src/transaction-manager.ts
| // Copyright (c) 2026 The Bitcoin developers | // Copyright (c) 2026 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 { Wallet } from 'ecash-wallet'; | import { Wallet } from 'ecash-wallet'; | ||||
| import { ChronikClient } from 'chronik-client'; | import { ChronikClient } from 'chronik-client'; | ||||
| import { calculateTransactionAmountSats, satsToXec } from './amount'; | import { calculateTransactionAmountAtoms, atomsToUnit } from './amount'; | ||||
| import { config } from './config'; | import { activeAssetDecimals, activeAssetTicker } from './active-asset'; | ||||
| import { webViewError, webViewLog } from './common'; | import { webViewError, webViewLog } from './common'; | ||||
| interface PendingTransaction { | interface PendingTransaction { | ||||
| // Positive = receive, negative = send, 0 = receive (in satoshis) | /** satoshis (XEC) or token atoms */ | ||||
| amountSats: number; | amountAtoms: number; | ||||
| state: 'pending_finalization' | 'finalized'; | state: 'pending_finalization' | 'finalized'; | ||||
| } | } | ||||
| export enum PostConsensusFinalizationResult { | export enum PostConsensusFinalizationResult { | ||||
| NOT_PENDING, | NOT_PENDING, | ||||
| ALREADY_FINALIZED, | ALREADY_FINALIZED, | ||||
| NEWLY_FINALIZED, | NEWLY_FINALIZED, | ||||
| } | } | ||||
| export interface TransactionManagerParams { | export interface TransactionManagerParams { | ||||
| ecashWallet: Wallet | null; | ecashWallet: Wallet | null; | ||||
| chronik: ChronikClient; | chronik: ChronikClient; | ||||
| /** null = track XEC (sats); otherwise track this token's atoms */ | |||||
| tokenId: string | null; | |||||
| onBalanceChange: ( | onBalanceChange: ( | ||||
| fromAvailableBalanceSats: number, | fromAvailableBalanceAtoms: number, | ||||
| toAvailableBalanceSats: number, | toAvailableBalanceAtoms: number, | ||||
| transitionalBalanceSats: number, | transitionalBalanceAtoms: number, | ||||
| ) => Promise<void>; | ) => Promise<void>; | ||||
| } | } | ||||
| export class TransactionManager { | export class TransactionManager { | ||||
| private params: TransactionManagerParams; | private params: TransactionManagerParams; | ||||
| private tokenId: string | null; | |||||
bytesofman: A transaction is not necessarily limited to a single tokenId. ALP to ALP swaps could be a… | |||||
FabienAuthorUnsubmitted Done Inline ActionsHere the tokenId refers to the active asset selected by the user, not the tokenId associated with a single transaction. If the user selects Firma the tokenId is the one from Firma, if he selects XEC then it's null. Fabien: Here the tokenId refers to the active asset selected by the user, not the tokenId associated… | |||||
| // Balance state - separate available and transitional (not finalized yet) | // Balance state - separate available and transitional (not finalized yet) | ||||
| // balances (in satoshis) | // balances (in satoshis) | ||||
| private availableBalanceSats = 0; // Only final amounts in satoshis | private availableBalanceAtoms = 0; | ||||
| private transitionalBalanceSats = 0; // Only non final amounts in satoshis | private transitionalBalanceAtoms = 0; | ||||
| // Pending transactions - transactions that are not yet finalized | // Pending transactions - transactions that are not yet finalized | ||||
| private pendingAmounts: { [txid: string]: PendingTransaction } = {}; | private pendingAmounts: { [txid: string]: PendingTransaction } = {}; | ||||
| constructor(params: TransactionManagerParams) { | constructor(params: TransactionManagerParams) { | ||||
| this.params = params; | this.params = params; | ||||
| this.tokenId = params.tokenId; | |||||
| this.sync(); | this.sync(); | ||||
| } | } | ||||
| // Update wallet reference | // Update wallet reference | ||||
| updateWallet(wallet: Wallet | null): void { | updateWallet(wallet: Wallet | null): void { | ||||
| this.params.ecashWallet = wallet; | this.params.ecashWallet = wallet; | ||||
| this.sync(); | this.sync(); | ||||
| } | } | ||||
| // Get current balance state | setTokenId(tokenId: string | null): void { | ||||
| this.tokenId = tokenId; | |||||
| this.pendingAmounts = {}; | |||||
| this.sync(); | |||||
| } | |||||
| getAvailableBalanceSats(): number { | getAvailableBalanceSats(): number { | ||||
| return this.availableBalanceSats; | return this.availableBalanceAtoms; | ||||
| } | } | ||||
| getTransitionalBalanceSats(): number { | getTransitionalBalanceSats(): number { | ||||
| return this.transitionalBalanceSats; | return this.transitionalBalanceAtoms; | ||||
| } | } | ||||
| sync(): void { | sync(): void { | ||||
| if (!this.params.ecashWallet) { | if (!this.params.ecashWallet) { | ||||
| return; | return; | ||||
| } | } | ||||
| const spendableUtxos = this.params.ecashWallet.spendableSatsOnlyUtxos(); | if (this.tokenId === null) { | ||||
| this.syncXecBalances(); | |||||
| } else { | |||||
| this.syncTokenBalances(this.tokenId); | |||||
| } | |||||
| this.transitionalBalanceAtoms = this.calculateTransitionalBalance(); | |||||
| } | |||||
| private syncXecBalances(): void { | |||||
| const wallet = this.params.ecashWallet!; | |||||
| const spendableUtxos = wallet.spendableSatsOnlyUtxos(); | |||||
| const finalUtxos = spendableUtxos.filter(utxo => utxo.isFinal); | const finalUtxos = spendableUtxos.filter(utxo => utxo.isFinal); | ||||
| this.availableBalanceSats = Number( | this.availableBalanceAtoms = Number( | ||||
| finalUtxos.reduce((sum, utxo) => sum + utxo.sats, 0n), | finalUtxos.reduce((sum, utxo) => sum + utxo.sats, 0n), | ||||
| ); | ); | ||||
| const nonFinalUtxos = spendableUtxos.filter(utxo => !utxo.isFinal); | const nonFinalUtxos = spendableUtxos.filter(utxo => !utxo.isFinal); | ||||
| const byTxId = new Map<string, bigint>(); | |||||
| for (const utxo of nonFinalUtxos) { | for (const utxo of nonFinalUtxos) { | ||||
| this.pendingAmounts[utxo.outpoint.txid] = { | const id = utxo.outpoint.txid; | ||||
| amountSats: Number(utxo.sats), | byTxId.set(id, (byTxId.get(id) ?? 0n) + utxo.sats); | ||||
| } | |||||
| this.pendingAmounts = {}; | |||||
| for (const [txid, sats] of byTxId) { | |||||
| this.pendingAmounts[txid] = { | |||||
| amountAtoms: Number(sats), | |||||
| state: 'pending_finalization', | |||||
| }; | |||||
| } | |||||
| } | |||||
| private syncTokenBalances(tokenId: string): void { | |||||
| const wallet = this.params.ecashWallet!; | |||||
| const spendable = wallet | |||||
| .spendableUtxos() | |||||
| .filter( | |||||
| utxo => | |||||
| utxo.token?.tokenId === tokenId && !utxo.token.isMintBaton, | |||||
| ); | |||||
| const finalUtxos = spendable.filter(utxo => utxo.isFinal); | |||||
| this.availableBalanceAtoms = Number( | |||||
| finalUtxos.reduce( | |||||
| (sum, utxo) => sum + (utxo.token?.atoms ?? 0n), | |||||
| 0n, | |||||
| ), | |||||
| ); | |||||
| const nonFinal = spendable.filter(utxo => !utxo.isFinal); | |||||
| const byTxId = new Map<string, number>(); | |||||
| for (const utxo of nonFinal) { | |||||
| const id = utxo.outpoint.txid; | |||||
| const atoms = Number(utxo.token?.atoms ?? 0n); | |||||
| byTxId.set(id, (byTxId.get(id) ?? 0) + atoms); | |||||
| } | |||||
| this.pendingAmounts = {}; | |||||
| for (const [txid, amt] of byTxId) { | |||||
| this.pendingAmounts[txid] = { | |||||
| amountAtoms: amt, | |||||
| state: 'pending_finalization', | state: 'pending_finalization', | ||||
| }; | }; | ||||
| } | } | ||||
| this.transitionalBalanceSats = this.calculateTransitionalBalance(); | |||||
| } | } | ||||
| // Check if transaction is pending | |||||
| isPendingTransaction(txid: string): boolean { | isPendingTransaction(txid: string): boolean { | ||||
| return txid in this.pendingAmounts; | return txid in this.pendingAmounts; | ||||
| } | } | ||||
| // Add a non-final transaction to the pending amounts | // Add a non-final transaction to the pending amounts | ||||
| async addNonFinalTransaction( | async addNonFinalTransaction( | ||||
| txid: string, | txid: string, | ||||
| ): Promise<PendingTransaction | false> { | ): Promise<PendingTransaction | false> { | ||||
| const tx = await this.addPendingAmount(txid, 'pending_finalization'); | const tx = await this.addPendingAmount(txid, 'pending_finalization'); | ||||
| if (tx !== false) { | if (tx !== false) { | ||||
| // Update transitional balance | this.transitionalBalanceAtoms = this.calculateTransitionalBalance(); | ||||
| this.transitionalBalanceSats = this.calculateTransitionalBalance(); | |||||
| // Notify balance change | |||||
| await this.params.onBalanceChange( | await this.params.onBalanceChange( | ||||
| this.availableBalanceSats, | this.availableBalanceAtoms, | ||||
| this.availableBalanceSats, | this.availableBalanceAtoms, | ||||
| this.transitionalBalanceSats, | this.transitionalBalanceAtoms, | ||||
| ); | ); | ||||
| } | } | ||||
| return tx; | return tx; | ||||
| } | } | ||||
| // Finalize pre-consensus transaction | |||||
| async finalizePreConsensus(txid: string): Promise<void> { | async finalizePreConsensus(txid: string): Promise<void> { | ||||
| let tx: PendingTransaction | false; | let tx: PendingTransaction | false; | ||||
| if (this.pendingAmounts[txid]) { | if (this.pendingAmounts[txid]) { | ||||
| // We already have the transaction in our pending amounts, so we can | // We already have the transaction in our pending amounts, so we can | ||||
| // just update the state | // just update the state | ||||
| tx = this.pendingAmounts[txid]; | tx = this.pendingAmounts[txid]; | ||||
| tx.state = 'finalized'; | tx.state = 'finalized'; | ||||
| } else { | } else { | ||||
| const pending_tx = await this.addPendingAmount(txid, 'finalized'); | const pending_tx = await this.addPendingAmount(txid, 'finalized'); | ||||
| if (!pending_tx) { | if (!pending_tx) { | ||||
| return; | return; | ||||
| } | } | ||||
| tx = pending_tx; | tx = pending_tx; | ||||
| } | } | ||||
| await this.finalizeTransaction(tx.amountSats); | await this.finalizeTransaction(tx.amountAtoms); | ||||
| webViewLog( | webViewLog( | ||||
| `Pre-consensus finalized transaction ${txid}: ${satsToXec( | `Pre-consensus finalized transaction ${txid}: ${atomsToUnit( | ||||
| tx.amountSats, | tx.amountAtoms, | ||||
| )} ${config.ticker} moved to available balance, state set to finalized`, | activeAssetDecimals(), | ||||
| )} ${activeAssetTicker()} moved to available balance, state set to finalized`, | |||||
| ); | ); | ||||
| } | } | ||||
| // Finalize post-consensus transaction | |||||
| async finalizePostConsensus( | async finalizePostConsensus( | ||||
| txid: string, | txid: string, | ||||
| ): Promise<PostConsensusFinalizationResult> { | ): Promise<PostConsensusFinalizationResult> { | ||||
| const tx = this.pendingAmounts[txid]; | const tx = this.pendingAmounts[txid]; | ||||
| if (!tx) { | if (!tx) { | ||||
| return PostConsensusFinalizationResult.NOT_PENDING; | return PostConsensusFinalizationResult.NOT_PENDING; | ||||
| } | } | ||||
| const status = | const status = | ||||
| tx.state === 'pending_finalization' | tx.state === 'pending_finalization' | ||||
| ? PostConsensusFinalizationResult.NEWLY_FINALIZED | ? PostConsensusFinalizationResult.NEWLY_FINALIZED | ||||
| : PostConsensusFinalizationResult.ALREADY_FINALIZED; | : PostConsensusFinalizationResult.ALREADY_FINALIZED; | ||||
| if (status === PostConsensusFinalizationResult.NEWLY_FINALIZED) { | if (status === PostConsensusFinalizationResult.NEWLY_FINALIZED) { | ||||
| tx.state = 'finalized'; | tx.state = 'finalized'; | ||||
| await this.finalizeTransaction(tx.amountSats); | await this.finalizeTransaction(tx.amountAtoms); | ||||
| webViewLog(`Post-consensus finalized pending transaction ${txid}`); | webViewLog(`Post-consensus finalized pending transaction ${txid}`); | ||||
| } | } | ||||
| // We won't get any message for this transaction anymore. | // We won't get any message for this transaction anymore. | ||||
| // We don't need to recompute the transitional balance since it is | // We don't need to recompute the transitional balance since it is | ||||
| // either a no change or it has been done in the finalizeTransaction | // either a no change or it has been done in the finalizeTransaction | ||||
| // call already. | // call already. | ||||
| delete this.pendingAmounts[txid]; | delete this.pendingAmounts[txid]; | ||||
| return status; | return status; | ||||
| } | } | ||||
| // Invalidate a transaction (remove from pending) | // Invalidate a transaction (remove from pending) | ||||
| async invalidateTransaction(txid: string): Promise<void> { | async invalidateTransaction(txid: string): Promise<void> { | ||||
| delete this.pendingAmounts[txid]; | delete this.pendingAmounts[txid]; | ||||
| this.transitionalBalanceAtoms = this.calculateTransitionalBalance(); | |||||
| // Update transitional balance | |||||
| this.transitionalBalanceSats = this.calculateTransitionalBalance(); | |||||
| // Notify balance change | |||||
| await this.params.onBalanceChange( | await this.params.onBalanceChange( | ||||
| this.availableBalanceSats, | this.availableBalanceAtoms, | ||||
| this.availableBalanceSats, | this.availableBalanceAtoms, | ||||
| this.transitionalBalanceSats, | this.transitionalBalanceAtoms, | ||||
| ); | ); | ||||
| } | } | ||||
| // Add pending transaction amount | |||||
| private async addPendingAmount( | private async addPendingAmount( | ||||
| txid: string, | txid: string, | ||||
| state: 'pending_finalization' | 'finalized', | state: 'pending_finalization' | 'finalized', | ||||
| ): Promise<PendingTransaction | false> { | ): Promise<PendingTransaction | false> { | ||||
| if (this.pendingAmounts[txid]) { | if (this.pendingAmounts[txid]) { | ||||
| webViewLog( | webViewLog( | ||||
| `Transaction ${txid} already exists in pending amounts, ignoring duplicate`, | `Transaction ${txid} already exists in pending amounts, ignoring duplicate`, | ||||
| ); | ); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (!this.params.ecashWallet) { | if (!this.params.ecashWallet) { | ||||
| webViewError('Cannot add pending amount: wallet not loaded'); | webViewError('Cannot add pending amount: wallet not loaded'); | ||||
| return false; | return false; | ||||
| } | } | ||||
| const txAmountSats = await calculateTransactionAmountSats( | const txAmountAtoms = await calculateTransactionAmountAtoms( | ||||
| this.params.ecashWallet, | this.params.ecashWallet, | ||||
| this.params.chronik, | this.params.chronik, | ||||
| txid, | txid, | ||||
| this.tokenId, | |||||
| ); | ); | ||||
| if (txAmountSats == 0) { | if (txAmountAtoms === 0) { | ||||
| webViewLog(`Transaction ${txid} has no amount, ignoring`); | webViewLog(`Transaction ${txid} has no amount, ignoring`); | ||||
| return false; | return false; | ||||
| } | } | ||||
| this.pendingAmounts[txid] = { | this.pendingAmounts[txid] = { | ||||
| amountSats: txAmountSats, | amountAtoms: txAmountAtoms, | ||||
| state, | state, | ||||
| }; | }; | ||||
| webViewLog( | webViewLog( | ||||
| `Added pending transaction ${txid}: ${satsToXec(txAmountSats)} ${ | `Added pending transaction ${txid}: ${atomsToUnit( | ||||
| config.ticker | txAmountAtoms, | ||||
| } (${txAmountSats} sats, state: ${state})`, | activeAssetDecimals(), | ||||
| )} ${activeAssetTicker()} (${txAmountAtoms} atoms, state: ${state})`, | |||||
| ); | ); | ||||
| return this.pendingAmounts[txid]; | return this.pendingAmounts[txid]; | ||||
| } | } | ||||
| // Finalize a transaction | private async finalizeTransaction(amountAtoms: number): Promise<void> { | ||||
| private async finalizeTransaction(amountSats: number): Promise<void> { | const fromAvailable = this.availableBalanceAtoms; | ||||
| const fromAvailableBalanceSats = this.availableBalanceSats; | this.availableBalanceAtoms += amountAtoms; | ||||
| this.availableBalanceSats += amountSats; | this.transitionalBalanceAtoms = this.calculateTransitionalBalance(); | ||||
| // Calculate transitional balance | |||||
| this.transitionalBalanceSats = this.calculateTransitionalBalance(); | |||||
| // Notify balance change | |||||
| await this.params.onBalanceChange( | await this.params.onBalanceChange( | ||||
| fromAvailableBalanceSats, | fromAvailable, | ||||
| this.availableBalanceSats, | this.availableBalanceAtoms, | ||||
| this.transitionalBalanceSats, | this.transitionalBalanceAtoms, | ||||
| ); | ); | ||||
| } | } | ||||
| // Calculate transitional balance (helper function) | |||||
| private calculateTransitionalBalance(): number { | private calculateTransitionalBalance(): number { | ||||
| let balance = 0; | let balance = 0; | ||||
| for (const tx of Object.values(this.pendingAmounts).filter( | for (const tx of Object.values(this.pendingAmounts).filter( | ||||
| tx => tx.state === 'pending_finalization', | t => t.state === 'pending_finalization', | ||||
| )) { | )) { | ||||
| balance += tx.amountSats; | balance += tx.amountAtoms; | ||||
| } | } | ||||
| return balance; | return balance; | ||||
| } | } | ||||
| } | } | ||||
A transaction is not necessarily limited to a single tokenId. ALP to ALP swaps could be a common exception here.