From a3b1b88ce83f52839120939faf5cefa3829e9072 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sat, 16 May 2026 19:22:24 +0000 Subject: [PATCH 1/6] btc: return transaction sync errors --- backend/coins/btc/account.go | 5 +- .../btc/transactions/mocks/transactions.go | 8 +- .../coins/btc/transactions/transactions.go | 123 ++++++++++++------ .../btc/transactions/transactions_test.go | 2 +- .../coins/btc/transactions/verification.go | 4 +- 5 files changed, 91 insertions(+), 51 deletions(-) diff --git a/backend/coins/btc/account.go b/backend/coins/btc/account.go index 8efc6cc346..ebefc7d0fe 100644 --- a/backend/coins/btc/account.go +++ b/backend/coins/btc/account.go @@ -672,7 +672,10 @@ func (account *Account) onAddressStatus(address *addresses.AccountAddress, statu return } - account.transactions.UpdateAddressHistory(address.PubkeyScriptHashHex(), history) + if err := account.transactions.UpdateAddressHistory(address.PubkeyScriptHashHex(), history); err != nil { + account.reportFatalSyncError(err, "UpdateAddressHistory failed") + return + } account.incAndEmitSyncCounter() account.ensureAddresses() } diff --git a/backend/coins/btc/transactions/mocks/transactions.go b/backend/coins/btc/transactions/mocks/transactions.go index ddd81f91a0..00f936c149 100644 --- a/backend/coins/btc/transactions/mocks/transactions.go +++ b/backend/coins/btc/transactions/mocks/transactions.go @@ -33,7 +33,7 @@ var _ transactions.Interface = &InterfaceMock{} // TransactionsFunc: func(isChange func(blockchain.ScriptHashHex) bool) (accounts.OrderedTransactions, error) { // panic("mock out the Transactions method") // }, -// UpdateAddressHistoryFunc: func(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) { +// UpdateAddressHistoryFunc: func(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) error { // panic("mock out the UpdateAddressHistory method") // }, // } @@ -56,7 +56,7 @@ type InterfaceMock struct { TransactionsFunc func(isChange func(blockchain.ScriptHashHex) bool) (accounts.OrderedTransactions, error) // UpdateAddressHistoryFunc mocks the UpdateAddressHistory method. - UpdateAddressHistoryFunc func(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) + UpdateAddressHistoryFunc func(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) error // calls tracks calls to the methods. calls struct { @@ -203,7 +203,7 @@ func (mock *InterfaceMock) TransactionsCalls() []struct { } // UpdateAddressHistory calls UpdateAddressHistoryFunc. -func (mock *InterfaceMock) UpdateAddressHistory(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) { +func (mock *InterfaceMock) UpdateAddressHistory(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) error { if mock.UpdateAddressHistoryFunc == nil { panic("InterfaceMock.UpdateAddressHistoryFunc: method is nil but Interface.UpdateAddressHistory was just called") } @@ -217,7 +217,7 @@ func (mock *InterfaceMock) UpdateAddressHistory(scriptHashHex blockchain.ScriptH mock.lockUpdateAddressHistory.Lock() mock.calls.UpdateAddressHistory = append(mock.calls.UpdateAddressHistory, callInfo) mock.lockUpdateAddressHistory.Unlock() - mock.UpdateAddressHistoryFunc(scriptHashHex, txs) + return mock.UpdateAddressHistoryFunc(scriptHashHex, txs) } // UpdateAddressHistoryCalls gets all the calls that were made to UpdateAddressHistory. diff --git a/backend/coins/btc/transactions/transactions.go b/backend/coins/btc/transactions/transactions.go index e9247d6842..3b0392ddbf 100644 --- a/backend/coins/btc/transactions/transactions.go +++ b/backend/coins/btc/transactions/transactions.go @@ -58,7 +58,7 @@ type Interface interface { // UpdateAddressHistory should be called when initializing a wallet address, or when the history of // an address changes (a new transaction that touches it appears or disappears). The transactions // are downloaded and indexed. - UpdateAddressHistory(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) + UpdateAddressHistory(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) error } // Transactions handles wallet transactions: keeping an index of the transactions, inputs, (unspent) @@ -195,14 +195,14 @@ func (transactions *Transactions) processTxForAddress( tx *wire.MsgTx, height int, headerTimestamp *time.Time, -) { +) error { txInfo, err := dbTx.TxInfo(txHash) if err != nil { - transactions.log.WithError(err).Panic("Failed to retrieve tx info") + return errp.WithMessage(err, "failed to retrieve tx info") } if err := dbTx.PutTx(txHash, tx, height, headerTimestamp); err != nil { - transactions.log.WithError(err).Panic("Failed to put tx") + return errp.WithMessage(err, "failed to put tx") } if err := transactions.notifier.Put(txHash[:]); err != nil { @@ -216,9 +216,9 @@ func (transactions *Transactions) processTxForAddress( } if err := dbTx.AddAddressToTx(txHash, scriptHashHex); err != nil { - transactions.log.WithError(err).Panic("Failed to add address to tx") + return errp.WithMessage(err, "failed to add address to tx") } - transactions.processInputsAndOutputsForAddress(dbTx, scriptHashHex, txHash, tx) + return transactions.processInputsAndOutputsForAddress(dbTx, scriptHashHex, txHash, tx) } // Go through the tx and extract all inputs and outputs which touch the address. @@ -226,7 +226,7 @@ func (transactions *Transactions) processInputsAndOutputsForAddress( dbTx DBTxInterface, scriptHashHex blockchain.ScriptHashHex, txHash chainhash.Hash, - tx *wire.MsgTx) { + tx *wire.MsgTx) error { // Gather transaction inputs that spend outputs of the given address. for _, txIn := range tx.TxIn { // Since transactions can be processed in any order, and we might process the same tx @@ -235,7 +235,7 @@ func (transactions *Transactions) processInputsAndOutputsForAddress( // since the output that it spends might be indexed later. txInTxHash, err := dbTx.Input(txIn.PreviousOutPoint) if err != nil { - transactions.log.WithError(err).Panic("Failed to retrieve input from previous outpoint") + return errp.WithMessage(err, "failed to retrieve input from previous outpoint") } if txInTxHash != nil && *txInTxHash != txHash { transactions.log.WithFields(logrus.Fields{"txIn.PreviousOutPoint": txIn.PreviousOutPoint, @@ -243,7 +243,7 @@ func (transactions *Transactions) processInputsAndOutputsForAddress( Warning("Double spend detected") } if err := dbTx.PutInput(txIn.PreviousOutPoint, txHash); err != nil { - transactions.log.WithError(err).Panic("Failed to store the transaction input") + return errp.WithMessage(err, "failed to store the transaction input") } } // Gather transaction outputs that belong to us. @@ -255,23 +255,24 @@ func (transactions *Transactions) processInputsAndOutputsForAddress( txOut, ) if err != nil { - transactions.log.WithError(err).Panic("Failed to store the transaction output") + return errp.WithMessage(err, "failed to store the transaction output") } } } + return nil } -func (transactions *Transactions) allInputsOurs(dbTx DBTxInterface, transaction *wire.MsgTx) bool { +func (transactions *Transactions) allInputsOurs(dbTx DBTxInterface, transaction *wire.MsgTx) (bool, error) { for _, txIn := range transaction.TxIn { txOut, err := dbTx.Output(txIn.PreviousOutPoint) if err != nil { - transactions.log.WithError(err).Panic("Failed to retrieve output") + return false, errp.WithMessage(err, "failed to retrieve output") } if txOut == nil { - return false + return false, nil } } - return true + return true, nil } // SpendableOutputs returns all unspent outputs of the wallet which are eligible to be spent. Those @@ -285,7 +286,10 @@ func (transactions *Transactions) SpendableOutputs() (map[wire.OutPoint]*Spendab } result := map[wire.OutPoint]*SpendableOutput{} for outPoint, txOut := range outputs { - spent := transactions.isInputSpent(dbTx, outPoint) + spent, err := transactions.isInputSpent(dbTx, outPoint) + if err != nil { + return nil, err + } if !spent { txInfo, err := dbTx.TxInfo(outPoint.Hash) if err != nil { @@ -293,7 +297,15 @@ func (transactions *Transactions) SpendableOutputs() (map[wire.OutPoint]*Spendab } confirmed := txInfo.Height > 0 - if confirmed || transactions.allInputsOurs(dbTx, txInfo.Tx) { + spendable := confirmed + if !spendable { + allInputsOurs, err := transactions.allInputsOurs(dbTx, txInfo.Tx) + if err != nil { + return nil, err + } + spendable = allInputsOurs + } + if spendable { result[outPoint] = &SpendableOutput{ TxOut: txOut, HeaderTimestamp: txInfo.HeaderTimestamp, @@ -305,31 +317,31 @@ func (transactions *Transactions) SpendableOutputs() (map[wire.OutPoint]*Spendab }) } -func (transactions *Transactions) isInputSpent(dbTx DBTxInterface, outPoint wire.OutPoint) bool { +func (transactions *Transactions) isInputSpent(dbTx DBTxInterface, outPoint wire.OutPoint) (bool, error) { input, err := dbTx.Input(outPoint) if err != nil { - transactions.log.WithError(err).Panic("Failed to retrieve input for outPoint") + return false, errp.WithMessage(err, "failed to retrieve input for outPoint") } - return input != nil + return input != nil, nil } func (transactions *Transactions) removeTxForAddress( - dbTx DBTxInterface, scriptHashHex blockchain.ScriptHashHex, txHash chainhash.Hash) { + dbTx DBTxInterface, scriptHashHex blockchain.ScriptHashHex, txHash chainhash.Hash) error { transactions.log.Debug("Remove transaction for address") txInfo, err := dbTx.TxInfo(txHash) if err != nil { - transactions.log.WithError(err).Panic("Failed to retrieve tx info") + return errp.WithMessage(err, "failed to retrieve tx info") } if txInfo == nil { // Not yet indexed. transactions.log.Debug("Transaction hash not listed") - return + return nil } transactions.log.Debug("Deleting transaction address") empty, err := dbTx.RemoveAddressFromTx(txHash, scriptHashHex) if err != nil { - transactions.log.WithError(err).Panic("Failed to remove address from tx") + return errp.WithMessage(err, "failed to remove address from tx") } if empty { // Tx is not touching any of our outputs anymore. Remove. @@ -352,15 +364,16 @@ func (transactions *Transactions) removeTxForAddress( transactions.log.WithError(err).Error("Failed notifier.Delete") } } + return nil } // UpdateAddressHistory should be called when initializing a wallet address, or when the history of // an address changes (a new transaction that touches it appears or disappears). The transactions // are downloaded and indexed. -func (transactions *Transactions) UpdateAddressHistory(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) { +func (transactions *Transactions) UpdateAddressHistory(scriptHashHex blockchain.ScriptHashHex, txs []*blockchain.TxInfo) error { if transactions.isClosed() { transactions.log.Debug("UpdateAddressHistory after the instance was closed") - return + return nil } err := DBUpdate(transactions.db, func(dbTx DBTxInterface) error { txsSet := map[chainhash.Hash]struct{}{} @@ -381,7 +394,9 @@ func (transactions *Transactions) UpdateAddressHistory(scriptHashHex blockchain. // A tx was previously in the address history but is not anymore. If the tx was already // downloaded and indexed, it will be removed. If it is currently downloading (enqueued for // indexing), it will not be processed. - transactions.removeTxForAddress(dbTx, scriptHashHex, entry.TXHash.Hash()) + if err := transactions.removeTxForAddress(dbTx, scriptHashHex, entry.TXHash.Hash()); err != nil { + return err + } } if err := dbTx.PutAddressHistory(scriptHashHex, txs); err != nil { @@ -390,14 +405,22 @@ func (transactions *Transactions) UpdateAddressHistory(scriptHashHex blockchain. for _, txInfo := range txs { txHash := txInfo.TXHash.Hash() height := txInfo.Height - tx, headerTimestamp := transactions.getTransactionCached(dbTx, txHash, height) - transactions.processTxForAddress(dbTx, scriptHashHex, txHash, tx, height, headerTimestamp) + tx, headerTimestamp, err := transactions.getTransactionCached(dbTx, txHash, height) + if err != nil { + return err + } + if err := transactions.processTxForAddress( + dbTx, scriptHashHex, txHash, tx, height, headerTimestamp, + ); err != nil { + return err + } } return nil }) if err != nil { - transactions.log.WithError(err).Panic("Failed to update address history") + return errp.WithMessage(err, "failed to update address history") } + return nil } // getTransactionCached requires transactions lock. @@ -405,22 +428,22 @@ func (transactions *Transactions) getTransactionCached( dbTx DBTxInterface, txHash chainhash.Hash, height int, -) (*wire.MsgTx, *time.Time) { +) (*wire.MsgTx, *time.Time, error) { txInfo, err := dbTx.TxInfo(txHash) if err != nil { - transactions.log.WithError(err).Panic("Failed to retrieve transaction info") + return nil, nil, errp.WithMessage(err, "failed to retrieve transaction info") } headerTimestamp := transactions.getCachedTimestampAtHeight(height, txInfo.HeaderTimestamp) if txInfo.Tx != nil { - return txInfo.Tx, headerTimestamp + return txInfo.Tx, headerTimestamp, nil } tx, err := transactions.blockchain.TransactionGet(txHash) if err != nil { - transactions.log.WithError(err).Panic("TransactionGet failed") + return nil, nil, errp.WithMessage(err, "TransactionGet failed") } - return tx, headerTimestamp + return tx, headerTimestamp, nil } // Balance computes the confirmed and unconfirmed balance of the account. @@ -433,7 +456,11 @@ func (transactions *Transactions) Balance() (*accounts.Balance, error) { var available, incoming int64 for outPoint, txOut := range outputs { // What is spent can not be available nor incoming. - if spent := transactions.isInputSpent(dbTx, outPoint); spent { + spent, err := transactions.isInputSpent(dbTx, outPoint) + if err != nil { + return nil, err + } + if spent { continue } txInfo, err := dbTx.TxInfo(outPoint.Hash) @@ -441,7 +468,15 @@ func (transactions *Transactions) Balance() (*accounts.Balance, error) { return nil, err } confirmed := txInfo.Height > 0 - if confirmed || transactions.allInputsOurs(dbTx, txInfo.Tx) { + availableOutput := confirmed + if !availableOutput { + allInputsOurs, err := transactions.allInputsOurs(dbTx, txInfo.Tx) + if err != nil { + return nil, err + } + availableOutput = allInputsOurs + } + if availableOutput { available += txOut.Value } else { incoming += txOut.Value @@ -464,15 +499,14 @@ func (transactions *Transactions) outputToAddress(pkScript []byte) string { func (transactions *Transactions) txInfo( dbTx DBTxInterface, txInfo *DBTxInfo, - isChange func(blockchain.ScriptHashHex) bool) *accounts.TransactionData { + isChange func(blockchain.ScriptHashHex) bool) (*accounts.TransactionData, error) { var sumOurInputs btcutil.Amount var result btcutil.Amount allInputsOurs := true for _, txIn := range txInfo.Tx.TxIn { spentOut, err := dbTx.Output(txIn.PreviousOutPoint) if err != nil { - // TODO - transactions.log.WithError(err).Panic("Output() failed") + return nil, errp.WithMessage(err, "Output() failed") } if spentOut != nil { sumOurInputs += btcutil.Amount(spentOut.Value) @@ -491,8 +525,7 @@ func (transactions *Transactions) txInfo( Index: uint32(index), }) if err != nil { - // TODO - transactions.log.WithError(err).Panic("Output() failed") + return nil, errp.WithMessage(err, "Output() failed") } addressAndAmount := accounts.AddressAndAmount{ Address: transactions.outputToAddress(txOut.PkScript), @@ -580,7 +613,7 @@ func (transactions *Transactions) txInfo( Weight: btcdBlockchain.GetTransactionWeight(btcutilTx), CreatedTimestamp: txInfo.CreatedTimestamp, IsErc20: false, - } + }, nil } // Transactions returns an ordered list of transactions. @@ -597,7 +630,11 @@ func (transactions *Transactions) Transactions( if err != nil { return nil, err } - txs = append(txs, transactions.txInfo(dbTx, txInfo, isChange)) + txData, err := transactions.txInfo(dbTx, txInfo, isChange) + if err != nil { + return nil, err + } + txs = append(txs, txData) } return accounts.NewOrderedTransactions(txs), nil }) diff --git a/backend/coins/btc/transactions/transactions_test.go b/backend/coins/btc/transactions/transactions_test.go index a4ecff863d..8238f009ab 100644 --- a/backend/coins/btc/transactions/transactions_test.go +++ b/backend/coins/btc/transactions/transactions_test.go @@ -134,7 +134,7 @@ func (s *transactionsSuite) updateAddressHistory( s.notifierMock.On("Put", tx.TXHash[:]).Return(nil).Once() } - s.transactions.UpdateAddressHistory(address.PubkeyScriptHashHex(), txs) + s.Require().NoError(s.transactions.UpdateAddressHistory(address.PubkeyScriptHashHex(), txs)) } func newTx( diff --git a/backend/coins/btc/transactions/verification.go b/backend/coins/btc/transactions/verification.go index 0cb8c38330..b6b454a4ba 100644 --- a/backend/coins/btc/transactions/verification.go +++ b/backend/coins/btc/transactions/verification.go @@ -51,8 +51,8 @@ func hashMerkleRoot(merkle []blockchain.TXHash, start chainhash.Hash, pos int) c func (transactions *Transactions) verifyTransactions() { unverifiedTransactions, err := transactions.unverifiedTransactions() if err != nil { - // TODO - panic(err) + transactions.log.WithError(err).Error("unverifiedTransactions") + return } transactions.log.Debugf("verifying %d transactions", len(unverifiedTransactions)) for txHash, height := range unverifiedTransactions { From e34f85a51faef511fe012d55f38cd11fd65ff8b7 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sat, 16 May 2026 19:23:33 +0000 Subject: [PATCH 2/6] btc: return transaction db errors --- .../btc/db/transactionsdb/transactionsdb.go | 34 +++++++++---------- .../db/transactionsdb/transactionsdb_test.go | 10 +++--- backend/coins/btc/transactions/db.go | 6 ++-- .../coins/btc/transactions/transactions.go | 14 +++++--- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/backend/coins/btc/db/transactionsdb/transactionsdb.go b/backend/coins/btc/db/transactionsdb/transactionsdb.go index 5d67a86f01..b2837127d9 100644 --- a/backend/coins/btc/db/transactionsdb/transactionsdb.go +++ b/backend/coins/btc/db/transactionsdb/transactionsdb.go @@ -155,16 +155,16 @@ func (tx *Tx) PutTx(txHash chainhash.Hash, msgTx *wire.MsgTx, height int, header return nil } -// DeleteTx implements transactions.DBTxInterface. It panics if called from a read-only db -// transaction. -func (tx *Tx) DeleteTx(txHash chainhash.Hash) { +// DeleteTx implements transactions.DBTxInterface. +func (tx *Tx) DeleteTx(txHash chainhash.Hash) error { bucketTransactions, err := tx.tx.CreateBucketIfNotExists([]byte(bucketTransactionsKey)) if err != nil { - panic(errp.WithStack(err)) + return errp.WithStack(err) } if err := bucketTransactions.Delete(txHash[:]); err != nil { - panic(errp.WithStack(err)) + return errp.WithStack(err) } + return nil } // AddAddressToTx implements transactions.DBTxInterface. @@ -215,10 +215,10 @@ func (tx *Tx) UnverifiedTransactions() ([]chainhash.Hash, error) { func (tx *Tx) MarkTxVerified(txHash chainhash.Hash, headerTimestamp time.Time) error { bucketUnverifiedTransactions, err := tx.tx.CreateBucketIfNotExists([]byte(bucketUnverifiedTransactionsKey)) if err != nil { - panic(errp.WithStack(err)) + return errp.WithStack(err) } if err := bucketUnverifiedTransactions.Delete(txHash[:]); err != nil { - panic(errp.WithStack(err)) + return errp.WithStack(err) } return tx.modifyTx(txHash[:], func(walletTx *transactions.DBTxInfo) { truth := true @@ -248,16 +248,16 @@ func (tx *Tx) Input(outPoint wire.OutPoint) (*chainhash.Hash, error) { return nil, nil } -// DeleteInput implements transactions.DBTxInterface. It panics if called from a read-only db -// transaction. -func (tx *Tx) DeleteInput(outPoint wire.OutPoint) { +// DeleteInput implements transactions.DBTxInterface. +func (tx *Tx) DeleteInput(outPoint wire.OutPoint) error { bucketInputs, err := tx.tx.CreateBucketIfNotExists([]byte(bucketInputsKey)) if err != nil { - panic(errp.WithStack(err)) + return errp.WithStack(err) } if err := bucketInputs.Delete([]byte(outPoint.String())); err != nil { - panic(errp.WithStack(err)) + return errp.WithStack(err) } + return nil } // PutOutput implements transactions.DBTxInterface. @@ -308,16 +308,16 @@ func (tx *Tx) Outputs() (map[wire.OutPoint]*wire.TxOut, error) { return outputs, nil } -// DeleteOutput implements transactions.DBTxInterface. It panics if called from a read-only db -// transaction. -func (tx *Tx) DeleteOutput(outPoint wire.OutPoint) { +// DeleteOutput implements transactions.DBTxInterface. +func (tx *Tx) DeleteOutput(outPoint wire.OutPoint) error { bucketOutputs, err := tx.tx.CreateBucketIfNotExists([]byte(bucketOutputsKey)) if err != nil { - panic(errp.WithStack(err)) + return errp.WithStack(err) } if err := bucketOutputs.Delete([]byte(outPoint.String())); err != nil { - panic(errp.WithStack(err)) + return errp.WithStack(err) } + return nil } // PutAddressHistory implements transactions.DBTxInterface. diff --git a/backend/coins/btc/db/transactionsdb/transactionsdb_test.go b/backend/coins/btc/db/transactionsdb/transactionsdb_test.go index 24acda39c2..04343c710a 100644 --- a/backend/coins/btc/db/transactionsdb/transactionsdb_test.go +++ b/backend/coins/btc/db/transactionsdb/transactionsdb_test.go @@ -263,7 +263,7 @@ func TestTxQuick(t *testing.T) { require.True(t, !txInfo.CreatedTimestamp.After(now) || *txInfo.CreatedTimestamp == now) - tx.DeleteTx(txHash) + require.NoError(t, tx.DeleteTx(txHash)) delete(allTxHashes, txHash) require.True(t, checkTxHashes()) }) @@ -328,7 +328,7 @@ func TestInput(t *testing.T) { require.Nil(t, input) // no-op, does not exist yet - tx.DeleteInput(outpoint1) + require.NoError(t, tx.DeleteInput(outpoint1)) require.NoError(t, tx.PutInput(outpoint1, txhash1)) require.NoError(t, tx.PutInput(outpoint2, txhash2)) @@ -351,7 +351,7 @@ func TestInput(t *testing.T) { require.NoError(t, err) require.Equal(t, &txhash2, input) - tx.DeleteInput(outpoint1) + require.NoError(t, tx.DeleteInput(outpoint1)) input, err = tx.Input(outpoint1) require.NoError(t, err) require.Nil(t, input) @@ -376,7 +376,7 @@ func TestInputQuick(t *testing.T) { for _, outPoint := range allOutpoints { t.Run("", func(t *testing.T) { - tx.DeleteInput(outPoint) + require.NoError(t, tx.DeleteInput(outPoint)) txHash, err := tx.Input(outPoint) require.NoError(t, err) require.Nil(t, txHash) @@ -475,7 +475,7 @@ func TestOutputsQuick(t *testing.T) { for outPoint := range allOutputs { t.Run("", func(t *testing.T) { delete(allOutputs, outPoint) - tx.DeleteOutput(outPoint) + require.NoError(t, tx.DeleteOutput(outPoint)) require.True(t, checkOutputs()) output, err := tx.Output(outPoint) require.NoError(t, err) diff --git a/backend/coins/btc/transactions/db.go b/backend/coins/btc/transactions/db.go index 7abf281078..9f5aed96d8 100644 --- a/backend/coins/btc/transactions/db.go +++ b/backend/coins/btc/transactions/db.go @@ -41,7 +41,7 @@ type DBTxInterface interface { PutTx(txHash chainhash.Hash, tx *wire.MsgTx, height int, headerTimestamp *time.Time) error // DeleteTx deletes a transaction (nothing happens if not found). - DeleteTx(txHash chainhash.Hash) + DeleteTx(txHash chainhash.Hash) error // AddAddressToTx adds an address associated with a transaction. Retrieve them with `TxInfo()`. AddAddressToTx(chainhash.Hash, blockchain.ScriptHashHex) error @@ -69,7 +69,7 @@ type DBTxInterface interface { Input(wire.OutPoint) (*chainhash.Hash, error) // DeleteInput deletes an input (nothing happens if not found). - DeleteInput(wire.OutPoint) + DeleteInput(wire.OutPoint) error // PutOutput stores an Output. PutOutput(wire.OutPoint, *wire.TxOut) error @@ -79,7 +79,7 @@ type DBTxInterface interface { Outputs() (map[wire.OutPoint]*wire.TxOut, error) // DeleteOutput deletes an output (nothing happens if not found). - DeleteOutput(wire.OutPoint) + DeleteOutput(wire.OutPoint) error // PutAddressHistory stores an address history. PutAddressHistory(blockchain.ScriptHashHex, blockchain.TxHistory) error diff --git a/backend/coins/btc/transactions/transactions.go b/backend/coins/btc/transactions/transactions.go index 3b0392ddbf..2e618251f8 100644 --- a/backend/coins/btc/transactions/transactions.go +++ b/backend/coins/btc/transactions/transactions.go @@ -348,18 +348,24 @@ func (transactions *Transactions) removeTxForAddress( for _, txIn := range txInfo.Tx.TxIn { transactions.log.Debug("Deleting transaction iput") - dbTx.DeleteInput(txIn.PreviousOutPoint) + if err := dbTx.DeleteInput(txIn.PreviousOutPoint); err != nil { + return errp.WithMessage(err, "failed to delete input") + } } // Remove the outputs added by this tx. for index := range txInfo.Tx.TxOut { - dbTx.DeleteOutput(wire.OutPoint{ + if err := dbTx.DeleteOutput(wire.OutPoint{ Hash: txHash, Index: uint32(index), - }) + }); err != nil { + return errp.WithMessage(err, "failed to delete output") + } } - dbTx.DeleteTx(txHash) + if err := dbTx.DeleteTx(txHash); err != nil { + return errp.WithMessage(err, "failed to delete tx") + } if err := transactions.notifier.Delete(txHash[:]); err != nil { transactions.log.WithError(err).Error("Failed notifier.Delete") } From d5654004130a8c1a361333f749932152ef356a33 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sat, 16 May 2026 19:26:49 +0000 Subject: [PATCH 3/6] btc: return header sync errors --- backend/coins/btc/account.go | 4 ++- backend/coins/btc/coin.go | 12 ++++++-- backend/coins/btc/coin_test.go | 2 +- backend/coins/btc/headers/headers.go | 31 ++++++++++---------- backend/coins/btc/headers/headers_test.go | 2 +- backend/coins/btc/headers/mocks/Interface.go | 20 ++++++++++--- backend/coins/coin/coin.go | 2 +- backend/coins/coin/mocks/coin.go | 8 ++--- backend/coins/eth/account.go | 4 ++- backend/coins/eth/coin.go | 4 ++- 10 files changed, 56 insertions(+), 33 deletions(-) diff --git a/backend/coins/btc/account.go b/backend/coins/btc/account.go index ebefc7d0fe..008a09eda1 100644 --- a/backend/coins/btc/account.go +++ b/backend/coins/btc/account.go @@ -318,7 +318,9 @@ func (account *Account) Initialize() error { account.log.Debug("Connection to blockchain backend established") } } - account.coin.Initialize() + if err := account.coin.Initialize(); err != nil { + return err + } account.SetOffline(account.coin.Blockchain().ConnectionError()) account.coin.Blockchain().RegisterOnConnectionErrorChangedEvent(onConnectionStatusChanged) theHeaders := account.coin.Headers() diff --git a/backend/coins/btc/coin.go b/backend/coins/btc/coin.go index 3067df1595..7d34cbf26f 100644 --- a/backend/coins/btc/coin.go +++ b/backend/coins/btc/coin.go @@ -31,6 +31,7 @@ import ( // Coin models a Bitcoin-related coin. type Coin struct { initOnce sync.Once + initErr error code coinpkg.Code name string // unit is the main unit of the coin, e.g. 'BTC' @@ -90,7 +91,7 @@ func (coin *Coin) TstSetMakeBlockchain(f func() blockchain.Interface) { } // Initialize implements coinpkg.Coin. -func (coin *Coin) Initialize() { +func (coin *Coin) Initialize() error { coin.initOnce.Do(func() { // Init blockchain coin.blockchain = coin.makeBlockchain() @@ -107,14 +108,18 @@ func (coin *Coin) Initialize() { path.Join(coin.dbFolder, fmt.Sprintf("headers-%s.bin", coin.code)), coin.log) if err != nil { - coin.log.WithError(err).Panic("Could not open headers DB") + coin.initErr = errp.WithMessage(err, "could not open headers DB") + return } coin.headers = headers.NewHeaders( coin.net, db, coin.blockchain, coin.log) - coin.headers.Initialize() + if err := coin.headers.Initialize(); err != nil { + coin.initErr = errp.WithMessage(err, "could not initialize headers") + return + } coin.headers.SubscribeEvent(func(event headers.Event) { if event == headers.EventSyncing || event == headers.EventSynced { status, err := coin.headers.Status() @@ -129,6 +134,7 @@ func (coin *Coin) Initialize() { } }) }) + return coin.initErr } // Name implements coinpkg.Coin. diff --git a/backend/coins/btc/coin_test.go b/backend/coins/btc/coin_test.go index 0cc1506aac..f36ed3a5bf 100644 --- a/backend/coins/btc/coin_test.go +++ b/backend/coins/btc/coin_test.go @@ -52,7 +52,7 @@ func (s *testSuite) SetupTest() { } s.coin.TstSetMakeBlockchain(func() blockchain.Interface { return blockchainMock }) - s.coin.Initialize() + s.Require().NoError(s.coin.Initialize()) } func (s *testSuite) TearDownTest() { diff --git a/backend/coins/btc/headers/headers.go b/backend/coins/btc/headers/headers.go index dde32ee176..1b5846ccd0 100644 --- a/backend/coins/btc/headers/headers.go +++ b/backend/coins/btc/headers/headers.go @@ -82,9 +82,9 @@ const ( // Interface represents the public API of this package. // -//go:generate mockery -name Interface +//go:generate mockery --name Interface type Interface interface { - Initialize() + Initialize() error SubscribeEvent(f func(Event)) func() VerifiedHeaderByHeight(int) (*wire.BlockHeader, error) HeaderByHeight(int) (*wire.BlockHeader, error) @@ -180,8 +180,12 @@ func (headers *Headers) TipHeight() int { } // Initialize starts the syncing process. -func (headers *Headers) Initialize() { - headers.tipAtInitTime = headers.tip() +func (headers *Headers) Initialize() error { + tip, err := headers.db.Tip() + if err != nil { + return err + } + headers.tipAtInitTime = tip headers.log.Infof("last tip loaded: %d", headers.tipAtInitTime) go headers.download() go headers.blockchain.HeadersSubscribe( @@ -190,6 +194,7 @@ func (headers *Headers) Initialize() { }, ) headers.kickChan <- struct{}{} + return nil } func (headers *Headers) download() { @@ -370,15 +375,16 @@ func (headers *Headers) canConnect(db DBInterface, tip int, header *wire.BlockHe return nil } -func (headers *Headers) reorg(db DBInterface, tip int) { +func (headers *Headers) reorg(db DBInterface, tip int) error { // Simple reorg method: re-fetch headers up to the maximum reorg limit. The server can shorten // our chain by sending a fake header and set us back by `reorgLimit` blocks, but it needs to // contain the correct PoW to do so. newTip := max(tip-reorgLimit, -1) if err := db.RevertTo(newTip); err != nil { - panic(err) + return err } headers.kick() + return nil } func (headers *Headers) notifyEvent(event Event) { @@ -395,7 +401,9 @@ func (headers *Headers) processBatch( err := headers.canConnect(db, tip+1, header) if errp.Cause(err) == errPrevHash { headers.log.WithError(err).Infof("Reorg detected at height %d", tip+1) - headers.reorg(db, tip) + if err := headers.reorg(db, tip); err != nil { + return err + } return nil } if err != nil { @@ -463,15 +471,6 @@ func (headers *Headers) update(blockHeight int) { headers.notifyEvent(EventNewTip) } -func (headers *Headers) tip() int { - defer headers.lock.RLock()() - tip, err := headers.db.Tip() - if err != nil { - panic(err) - } - return tip -} - // Status returns the current sync status. func (headers *Headers) Status() (*Status, error) { defer headers.lock.RLock()() diff --git a/backend/coins/btc/headers/headers_test.go b/backend/coins/btc/headers/headers_test.go index ee44430787..aef5e0da58 100644 --- a/backend/coins/btc/headers/headers_test.go +++ b/backend/coins/btc/headers/headers_test.go @@ -23,7 +23,7 @@ func TestClose(t *testing.T) { headers.testDownloadFinished = func() { close(didFinish) } - headers.Initialize() + require.NoError(t, headers.Initialize()) require.NoError(t, headers.Close()) select { diff --git a/backend/coins/btc/headers/mocks/Interface.go b/backend/coins/btc/headers/mocks/Interface.go index 2a3a6d4244..8fca9e93a9 100644 --- a/backend/coins/btc/headers/mocks/Interface.go +++ b/backend/coins/btc/headers/mocks/Interface.go @@ -4,9 +4,8 @@ package mocks import ( headers "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/btc/headers" - mock "github.com/stretchr/testify/mock" - wire "github.com/btcsuite/btcd/wire" + mock "github.com/stretchr/testify/mock" ) // Interface is an autogenerated mock type for the Interface type @@ -45,8 +44,21 @@ func (_m *Interface) HeaderByHeight(_a0 int) (*wire.BlockHeader, error) { } // Initialize provides a mock function with no fields -func (_m *Interface) Initialize() { - _m.Called() +func (_m *Interface) Initialize() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Initialize") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 } // Status provides a mock function with no fields diff --git a/backend/coins/coin/coin.go b/backend/coins/coin/coin.go index 6fa7b87650..498e22553f 100644 --- a/backend/coins/coin/coin.go +++ b/backend/coins/coin/coin.go @@ -61,7 +61,7 @@ type Coin interface { BlockExplorerTransactionURLPrefix() string // Initialize initializes the coin by connecting to a full node, downloading the headers, etc. - Initialize() + Initialize() error // SmallestUnit returns the name of the smallest unit of a given coin SmallestUnit() string diff --git a/backend/coins/coin/mocks/coin.go b/backend/coins/coin/mocks/coin.go index e357d2862d..a81fa2d4a3 100644 --- a/backend/coins/coin/mocks/coin.go +++ b/backend/coins/coin/mocks/coin.go @@ -38,7 +38,7 @@ var _ coin.Coin = &CoinMock{} // GetFormatUnitFunc: func(isFee bool) string { // panic("mock out the GetFormatUnit method") // }, -// InitializeFunc: func() { +// InitializeFunc: func() error { // panic("mock out the Initialize method") // }, // NameFunc: func() string { @@ -88,7 +88,7 @@ type CoinMock struct { GetFormatUnitFunc func(isFee bool) string // InitializeFunc mocks the Initialize method. - InitializeFunc func() + InitializeFunc func() error // NameFunc mocks the Name method. NameFunc func() string @@ -376,7 +376,7 @@ func (mock *CoinMock) GetFormatUnitCalls() []struct { } // Initialize calls InitializeFunc. -func (mock *CoinMock) Initialize() { +func (mock *CoinMock) Initialize() error { if mock.InitializeFunc == nil { panic("CoinMock.InitializeFunc: method is nil but Coin.Initialize was just called") } @@ -385,7 +385,7 @@ func (mock *CoinMock) Initialize() { mock.lockInitialize.Lock() mock.calls.Initialize = append(mock.calls.Initialize, callInfo) mock.lockInitialize.Unlock() - mock.InitializeFunc() + return mock.InitializeFunc() } // InitializeCalls gets all the calls that were made to Initialize. diff --git a/backend/coins/eth/account.go b/backend/coins/eth/account.go index c32dfb6669..ffc8df4480 100644 --- a/backend/coins/eth/account.go +++ b/backend/coins/eth/account.go @@ -191,7 +191,9 @@ func (account *Account) Initialize() error { account.signingConfiguration.ExtendedPublicKey(), ) - account.coin.Initialize() + if err := account.coin.Initialize(); err != nil { + return err + } account.initDone = account.Synchronizer.IncRequestsCounter() if !account.Config().SkipInitialSync { go account.EnqueueUpdate() diff --git a/backend/coins/eth/coin.go b/backend/coins/eth/coin.go index 3b406c48e1..6b7b1e6d31 100644 --- a/backend/coins/eth/coin.go +++ b/backend/coins/eth/coin.go @@ -100,7 +100,9 @@ func (coin *Coin) ChainIDstr() string { } // Initialize implements coin.Coin. -func (coin *Coin) Initialize() {} +func (coin *Coin) Initialize() error { + return nil +} // Name implements coin.Coin. func (coin *Coin) Name() string { From 2379f3b80d7bb5b8fc90114ac0a3006758fe6c36 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sat, 16 May 2026 19:27:43 +0000 Subject: [PATCH 4/6] accounts: return keypath parse errors --- backend/accounts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/accounts.go b/backend/accounts.go index c972e80498..fe9ce746f6 100644 --- a/backend/accounts.go +++ b/backend/accounts.go @@ -1267,7 +1267,7 @@ func (backend *Backend) persistETHAccountConfig( log.Info("persist account") absoluteKeypath, err := signing.NewAbsoluteKeypath(keypath) if err != nil { - panic(err) + return errp.WithMessage(err, "could not parse keypath") } extendedPublicKey, err := keystore.ExtendedPublicKey(coin, absoluteKeypath) if err != nil { From 2019f6391015965b948ccdf56f23a2599918f249 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sat, 16 May 2026 20:51:25 +0000 Subject: [PATCH 5/6] btc: clean up failed account init --- backend/coins/btc/account.go | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/coins/btc/account.go b/backend/coins/btc/account.go index 008a09eda1..d80de29791 100644 --- a/backend/coins/btc/account.go +++ b/backend/coins/btc/account.go @@ -282,7 +282,22 @@ func (account *Account) Initialize() error { if account.initialized { return nil } - account.initialized = true + defer func() { + if account.initialized { + return + } + if account.transactions != nil { + account.transactions.Close() + account.transactions = nil + } + if account.db != nil { + if closeErr := account.db.Close(); closeErr != nil { + account.log.WithError(closeErr).Error("couldn't close db") + } + account.db = nil + } + account.subaccounts = nil + }() signingConfigurations := account.Config().Config.SigningConfigurations if len(signingConfigurations) == 0 { @@ -322,7 +337,6 @@ func (account *Account) Initialize() error { return err } account.SetOffline(account.coin.Blockchain().ConnectionError()) - account.coin.Blockchain().RegisterOnConnectionErrorChangedEvent(onConnectionStatusChanged) theHeaders := account.coin.Headers() account.transactions = transactions.NewTransactions( account.coin.Net(), account.db, theHeaders, account.Synchronizer, @@ -346,9 +360,13 @@ func (account *Account) Initialize() error { account.subaccounts = append(account.subaccounts, subacc) } + if err := account.BaseAccount.Initialize(accountIdentifier); err != nil { + return err + } + account.coin.Blockchain().RegisterOnConnectionErrorChangedEvent(onConnectionStatusChanged) + account.initialized = true go account.ensureAddresses() - - return account.BaseAccount.Initialize(accountIdentifier) + return nil } // XPubVersionForScriptType returns the xpub version bytes for the given coin and script type. From b9f9352ca7ed95eca0e404b19c4b5e6c74f48219 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sat, 16 May 2026 20:52:44 +0000 Subject: [PATCH 6/6] btc: defer transaction side effects --- .../coins/btc/transactions/transactions.go | 82 ++++++++++++------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/backend/coins/btc/transactions/transactions.go b/backend/coins/btc/transactions/transactions.go index 2e618251f8..36c45b3014 100644 --- a/backend/coins/btc/transactions/transactions.go +++ b/backend/coins/btc/transactions/transactions.go @@ -195,30 +195,25 @@ func (transactions *Transactions) processTxForAddress( tx *wire.MsgTx, height int, headerTimestamp *time.Time, -) error { +) (bool, error) { txInfo, err := dbTx.TxInfo(txHash) if err != nil { - return errp.WithMessage(err, "failed to retrieve tx info") + return false, errp.WithMessage(err, "failed to retrieve tx info") } if err := dbTx.PutTx(txHash, tx, height, headerTimestamp); err != nil { - return errp.WithMessage(err, "failed to put tx") + return false, errp.WithMessage(err, "failed to put tx") } - if err := transactions.notifier.Put(txHash[:]); err != nil { - transactions.log.WithError(err).Error("Failed notifier.Put") - } - - // Newly confirmed tx. Try to verify it. - if txInfo.Height <= 0 && height > 0 { - transactions.log.Debug("Try to verify newly confirmed tx") - go transactions.verifyTransaction(txHash, height) - } + newlyConfirmed := txInfo.Height <= 0 && height > 0 if err := dbTx.AddAddressToTx(txHash, scriptHashHex); err != nil { - return errp.WithMessage(err, "failed to add address to tx") + return false, errp.WithMessage(err, "failed to add address to tx") } - return transactions.processInputsAndOutputsForAddress(dbTx, scriptHashHex, txHash, tx) + if err := transactions.processInputsAndOutputsForAddress(dbTx, scriptHashHex, txHash, tx); err != nil { + return false, err + } + return newlyConfirmed, nil } // Go through the tx and extract all inputs and outputs which touch the address. @@ -326,22 +321,22 @@ func (transactions *Transactions) isInputSpent(dbTx DBTxInterface, outPoint wire } func (transactions *Transactions) removeTxForAddress( - dbTx DBTxInterface, scriptHashHex blockchain.ScriptHashHex, txHash chainhash.Hash) error { + dbTx DBTxInterface, scriptHashHex blockchain.ScriptHashHex, txHash chainhash.Hash) (bool, error) { transactions.log.Debug("Remove transaction for address") txInfo, err := dbTx.TxInfo(txHash) if err != nil { - return errp.WithMessage(err, "failed to retrieve tx info") + return false, errp.WithMessage(err, "failed to retrieve tx info") } if txInfo == nil { // Not yet indexed. transactions.log.Debug("Transaction hash not listed") - return nil + return false, nil } transactions.log.Debug("Deleting transaction address") empty, err := dbTx.RemoveAddressFromTx(txHash, scriptHashHex) if err != nil { - return errp.WithMessage(err, "failed to remove address from tx") + return false, errp.WithMessage(err, "failed to remove address from tx") } if empty { // Tx is not touching any of our outputs anymore. Remove. @@ -349,7 +344,7 @@ func (transactions *Transactions) removeTxForAddress( for _, txIn := range txInfo.Tx.TxIn { transactions.log.Debug("Deleting transaction iput") if err := dbTx.DeleteInput(txIn.PreviousOutPoint); err != nil { - return errp.WithMessage(err, "failed to delete input") + return false, errp.WithMessage(err, "failed to delete input") } } @@ -359,18 +354,15 @@ func (transactions *Transactions) removeTxForAddress( Hash: txHash, Index: uint32(index), }); err != nil { - return errp.WithMessage(err, "failed to delete output") + return false, errp.WithMessage(err, "failed to delete output") } } if err := dbTx.DeleteTx(txHash); err != nil { - return errp.WithMessage(err, "failed to delete tx") - } - if err := transactions.notifier.Delete(txHash[:]); err != nil { - transactions.log.WithError(err).Error("Failed notifier.Delete") + return false, errp.WithMessage(err, "failed to delete tx") } } - return nil + return empty, nil } // UpdateAddressHistory should be called when initializing a wallet address, or when the history of @@ -381,6 +373,13 @@ func (transactions *Transactions) UpdateAddressHistory(scriptHashHex blockchain. transactions.log.Debug("UpdateAddressHistory after the instance was closed") return nil } + type txToVerify struct { + txHash chainhash.Hash + height int + } + var txsToNotify []chainhash.Hash + var txsToVerify []txToVerify + var txsToDeleteFromNotifier []chainhash.Hash err := DBUpdate(transactions.db, func(dbTx DBTxInterface) error { txsSet := map[chainhash.Hash]struct{}{} for _, txInfo := range txs { @@ -400,9 +399,13 @@ func (transactions *Transactions) UpdateAddressHistory(scriptHashHex blockchain. // A tx was previously in the address history but is not anymore. If the tx was already // downloaded and indexed, it will be removed. If it is currently downloading (enqueued for // indexing), it will not be processed. - if err := transactions.removeTxForAddress(dbTx, scriptHashHex, entry.TXHash.Hash()); err != nil { + removed, err := transactions.removeTxForAddress(dbTx, scriptHashHex, entry.TXHash.Hash()) + if err != nil { return err } + if removed { + txsToDeleteFromNotifier = append(txsToDeleteFromNotifier, entry.TXHash.Hash()) + } } if err := dbTx.PutAddressHistory(scriptHashHex, txs); err != nil { @@ -415,17 +418,40 @@ func (transactions *Transactions) UpdateAddressHistory(scriptHashHex blockchain. if err != nil { return err } - if err := transactions.processTxForAddress( + newlyConfirmed, err := transactions.processTxForAddress( dbTx, scriptHashHex, txHash, tx, height, headerTimestamp, - ); err != nil { + ) + if err != nil { return err } + txsToNotify = append(txsToNotify, txHash) + if newlyConfirmed { + txsToVerify = append(txsToVerify, txToVerify{txHash: txHash, height: height}) + } } return nil }) if err != nil { return errp.WithMessage(err, "failed to update address history") } + for _, txHash := range txsToDeleteFromNotifier { + if err := transactions.notifier.Delete(txHash[:]); err != nil { + transactions.log.WithError(err).Error("Failed notifier.Delete") + } + } + for _, txHash := range txsToNotify { + if err := transactions.notifier.Put(txHash[:]); err != nil { + transactions.log.WithError(err).Error("Failed notifier.Put") + } + } + if len(txsToVerify) != 0 { + go func() { + for _, tx := range txsToVerify { + transactions.log.Debug("Try to verify newly confirmed tx") + transactions.verifyTransaction(tx.txHash, tx.height) + } + }() + } return nil }