diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d9a4db4..1ce134a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] - go-version: [ '1.20', '1.21' ] + go-version: [ '1.21', '1.22' ] runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: diff --git a/go.mod b/go.mod index b714b70..83b44b5 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go.sia.tech/coreutils -go 1.21 +go 1.21.6 require ( go.etcd.io/bbolt v1.3.8 diff --git a/testutil/wallet.go b/testutil/wallet.go index 0bcafec..75b40e3 100644 --- a/testutil/wallet.go +++ b/testutil/wallet.go @@ -2,6 +2,8 @@ package testutil import ( "fmt" + "slices" + "sort" "sync" "go.sia.tech/core/types" @@ -11,220 +13,116 @@ import ( // An EphemeralWalletStore is a Store that does not persist its state to disk. It is // primarily useful for testing or as a reference implementation. -type EphemeralWalletStore struct { - privateKey types.PrivateKey +type ( + EphemeralWalletStore struct { + privateKey types.PrivateKey - mu sync.Mutex - uncommitted []*chain.ApplyUpdate - tip types.ChainIndex - utxos map[types.Hash256]types.SiacoinElement + mu sync.Mutex + uncommitted []*chain.ApplyUpdate - immaturePayoutTransactions map[uint64][]wallet.Transaction // transactions are never removed to simplify reorg handling - transactions []wallet.Transaction -} - -// ProcessChainRevertUpdate implements chain.Subscriber -func (es *EphemeralWalletStore) ProcessChainRevertUpdate(cru *chain.RevertUpdate) error { - es.mu.Lock() - defer es.mu.Unlock() - - // if the update is uncommitted, remove it from the uncommitted list - if len(es.uncommitted) > 0 && es.uncommitted[len(es.uncommitted)-1].State.Index == cru.State.Index { - es.uncommitted = es.uncommitted[:len(es.uncommitted)-1] - return nil + tip types.ChainIndex + utxos map[types.SiacoinOutputID]wallet.SiacoinElement + events []wallet.Event } - walletAddress := types.StandardUnlockHash(es.privateKey.PublicKey()) - - // revert any siacoin element changes - cru.ForEachSiacoinElement(func(se types.SiacoinElement, spent bool) { - if se.SiacoinOutput.Address != walletAddress { - return - } - - if spent { - es.utxos[se.ID] = se - } else { - delete(es.utxos, se.ID) - } - }) - - // remove any transactions that were added in the reverted block - filtered := es.transactions[:0] - for _, txn := range es.transactions { - if txn.Index == cru.State.Index { - continue - } - filtered = append(filtered, txn) + ephemeralWalletUpdateTxn struct { + store *EphemeralWalletStore } +) - // update element proofs - for id, se := range es.utxos { - cru.UpdateElementProof(&se.StateElement) - es.utxos[id] = se +func (et *ephemeralWalletUpdateTxn) WalletStateElements() (elements []types.StateElement, _ error) { + for _, se := range et.store.utxos { + elements = append(elements, se.StateElement) } - return nil + return } -// ProcessChainApplyUpdate implements chain.Subscriber -func (es *EphemeralWalletStore) ProcessChainApplyUpdate(cau *chain.ApplyUpdate, commit bool) error { - es.mu.Lock() - defer es.mu.Unlock() - - es.uncommitted = append(es.uncommitted, cau) - if !commit { - return nil +func (et *ephemeralWalletUpdateTxn) UpdateStateElements(elements []types.StateElement) error { + for _, se := range elements { + utxo := et.store.utxos[types.SiacoinOutputID(se.ID)] + utxo.StateElement = se + et.store.utxos[types.SiacoinOutputID(se.ID)] = utxo } + return nil +} - walletAddress := types.StandardUnlockHash(es.privateKey.PublicKey()) +func (et *ephemeralWalletUpdateTxn) AddEvents(events []wallet.Event) error { + et.store.events = append(events, et.store.events...) + return nil +} - for _, update := range es.uncommitted { - // cache the source of new immature outputs to show payout transactions - siacoinOutputSources := map[types.SiacoinOutputID]wallet.TransactionSource{ - update.Block.ID().FoundationOutputID(): wallet.TxnSourceFoundationPayout, - } - // add the miner payouts - for i := range update.Block.MinerPayouts { - siacoinOutputSources[update.Block.ID().MinerOutputID(i)] = wallet.TxnSourceMinerPayout +func (et *ephemeralWalletUpdateTxn) AddSiacoinElements(elements []wallet.SiacoinElement) error { + for _, se := range elements { + if _, ok := et.store.utxos[types.SiacoinOutputID(se.ID)]; ok { + return fmt.Errorf("siacoin element %q already exists", se.ID) } + et.store.utxos[types.SiacoinOutputID(se.ID)] = se + } + return nil +} - // add the file contract outputs - update.ForEachFileContractElement(func(fce types.FileContractElement, rev *types.FileContractElement, resolved bool, valid bool) { - if !resolved { - return - } - - if valid { - for i := range fce.FileContract.ValidProofOutputs { - siacoinOutputSources[types.FileContractID(fce.ID).ValidOutputID(i)] = wallet.TxnSourceContract - } - } else { - for i := range fce.FileContract.MissedProofOutputs { - siacoinOutputSources[types.FileContractID(fce.ID).MissedOutputID(i)] = wallet.TxnSourceContract - } - } - }) - - // add matured transactions first since - for _, txn := range es.immaturePayoutTransactions[update.State.Index.Height] { - txn.Index = update.State.Index - txn.Timestamp = update.Block.Timestamp - // prepend the transaction to the wallet - es.transactions = append([]wallet.Transaction{txn}, es.transactions...) +func (et *ephemeralWalletUpdateTxn) RemoveSiacoinElements(ids []types.SiacoinOutputID) error { + for _, id := range ids { + if _, ok := et.store.utxos[id]; !ok { + return fmt.Errorf("siacoin element %q does not exist", id) } + delete(et.store.utxos, id) + } + return nil +} - // add the block transactions - for _, txn := range update.Block.Transactions { - if !wallet.IsRelevantTransaction(txn, walletAddress) { - continue - } - - wt := wallet.Transaction{ - ID: txn.ID(), - Index: update.State.Index, - Transaction: txn, - Timestamp: update.Block.Timestamp, - Source: wallet.TxnSourceTransaction, - } - - for _, sci := range txn.SiacoinInputs { - if sci.UnlockConditions.UnlockHash() != walletAddress { - continue - } - - sce, ok := es.utxos[types.Hash256(sci.ParentID)] - if !ok { - return fmt.Errorf("missing relevant siacoin element %q", sci.ParentID) - } - wt.Outflow = wt.Outflow.Add(sce.SiacoinOutput.Value) - } - - for _, sco := range txn.SiacoinOutputs { - if sco.Address != walletAddress { - continue - } - - wt.Inflow = wt.Inflow.Add(sco.Value) - } - - // prepend the transaction to the wallet - es.transactions = append([]wallet.Transaction{wt}, es.transactions...) - - // add the siafund claim output IDs - for i := range txn.SiafundInputs { - siacoinOutputSources[txn.SiafundInputs[i].ParentID.ClaimOutputID()] = wallet.TxnSourceSiafundClaim - } +func (et *ephemeralWalletUpdateTxn) RevertIndex(index types.ChainIndex) error { + // remove any events that were added in the reverted block + filtered := et.store.events[:0] + for i := range et.store.events { + if et.store.events[i].Index == index { + continue } + filtered = append(filtered, et.store.events[i]) + } + et.store.events = filtered - // update the utxo set - update.ForEachSiacoinElement(func(se types.SiacoinElement, spent bool) { - if se.SiacoinOutput.Address != walletAddress { - return - } - - // update the utxo set - if spent { - delete(es.utxos, se.ID) - } else { - es.utxos[se.ID] = se - } - - // create an immature payout transaction for any immature siacoin outputs - if se.MaturityHeight == 0 || spent { - return - } - - source, ok := siacoinOutputSources[types.SiacoinOutputID(se.ID)] - if !ok { - panic("missing siacoin source") - } - - es.immaturePayoutTransactions[se.MaturityHeight] = append(es.immaturePayoutTransactions[se.MaturityHeight], wallet.Transaction{ - ID: types.TransactionID(se.ID), - Transaction: types.Transaction{SiacoinOutputs: []types.SiacoinOutput{se.SiacoinOutput}}, - Inflow: se.SiacoinOutput.Value, - Source: source, - // Index and Timestamp will be filled in later - }) - }) - - // update the element proofs - for id, se := range es.utxos { - cau.UpdateElementProof(&se.StateElement) - es.utxos[id] = se + // remove any siacoin elements that were added in the reverted block + for id, se := range et.store.utxos { + if se.Index == index { + delete(et.store.utxos, id) } } - - es.uncommitted = es.uncommitted[:0] - es.tip = cau.State.Index return nil } -// Transactions returns the wallet's transactions. -func (es *EphemeralWalletStore) Transactions(limit, offset int) ([]wallet.Transaction, error) { +// WalletEvents returns the wallet's events. +func (es *EphemeralWalletStore) WalletEvents(offset, limit int) ([]wallet.Event, error) { es.mu.Lock() defer es.mu.Unlock() - if offset > len(es.transactions) { + n := len(es.events) + start, end := offset, offset+limit + if start > n { return nil, nil + } else if end > n { + end = n } - - end := offset + limit - if end > len(es.transactions) { - end = len(es.transactions) - } - return es.transactions[offset:end], nil + // events are inserted in chronological order, reverse the slice to get the + // correct display order then sort by maturity height, so + // immature events are displayed first. + events := append([]wallet.Event(nil), es.events...) + slices.Reverse(events) + sort.SliceStable(events, func(i, j int) bool { + return events[i].MaturityHeight > events[j].MaturityHeight + }) + return events[start:end], nil } -// TransactionCount returns the number of transactions in the wallet. -func (es *EphemeralWalletStore) TransactionCount() (uint64, error) { +// WalletEventCount returns the number of events relevant to the wallet. +func (es *EphemeralWalletStore) WalletEventCount() (uint64, error) { es.mu.Lock() defer es.mu.Unlock() - return uint64(len(es.transactions)), nil + return uint64(len(es.events)), nil } // UnspentSiacoinElements returns the wallet's unspent siacoin outputs. -func (es *EphemeralWalletStore) UnspentSiacoinElements() (utxos []types.SiacoinElement, _ error) { +func (es *EphemeralWalletStore) UnspentSiacoinElements() (utxos []wallet.SiacoinElement, _ error) { es.mu.Lock() defer es.mu.Unlock() @@ -241,12 +139,46 @@ func (es *EphemeralWalletStore) Tip() (types.ChainIndex, error) { return es.tip, nil } +// ProcessChainApplyUpdate implements chain.Subscriber. +func (es *EphemeralWalletStore) ProcessChainApplyUpdate(cau *chain.ApplyUpdate, mayCommit bool) error { + es.mu.Lock() + defer es.mu.Unlock() + + es.uncommitted = append(es.uncommitted, cau) + if !mayCommit { + return nil + } + + address := types.StandardUnlockHash(es.privateKey.PublicKey()) + ephemeralWalletUpdateTxn := &ephemeralWalletUpdateTxn{store: es} + + if err := wallet.ApplyChainUpdates(ephemeralWalletUpdateTxn, address, es.uncommitted); err != nil { + return err + } + es.tip = cau.State.Index + es.uncommitted = nil + return nil +} + +// ProcessChainRevertUpdate implements chain.Subscriber. +func (es *EphemeralWalletStore) ProcessChainRevertUpdate(cru *chain.RevertUpdate) error { + es.mu.Lock() + defer es.mu.Unlock() + + if len(es.uncommitted) > 0 && es.uncommitted[len(es.uncommitted)-1].State.Index == cru.State.Index { + es.uncommitted = es.uncommitted[:len(es.uncommitted)-1] + return nil + } + + address := types.StandardUnlockHash(es.privateKey.PublicKey()) + return wallet.RevertChainUpdate(&ephemeralWalletUpdateTxn{store: es}, address, cru) +} + // NewEphemeralWalletStore returns a new EphemeralWalletStore. func NewEphemeralWalletStore(pk types.PrivateKey) *EphemeralWalletStore { return &EphemeralWalletStore{ privateKey: pk, - utxos: make(map[types.Hash256]types.SiacoinElement), - immaturePayoutTransactions: make(map[uint64][]wallet.Transaction), + utxos: make(map[types.SiacoinOutputID]wallet.SiacoinElement), } } diff --git a/wallet/update.go b/wallet/update.go new file mode 100644 index 0000000..3f3c6ed --- /dev/null +++ b/wallet/update.go @@ -0,0 +1,279 @@ +package wallet + +import ( + "fmt" + + "go.sia.tech/core/types" + "go.sia.tech/coreutils/chain" +) + +type ( + // ApplyTx is an interface for atomically applying a chain update to a + // single address wallet. + ApplyTx interface { + // WalletStateElements returns all state elements related to the wallet. It is used + // to update the proofs of all state elements affected by the update. + WalletStateElements() ([]types.StateElement, error) + // UpdateStateElements updates the proofs of all state elements affected by the + // update. + UpdateStateElements([]types.StateElement) error + + // AddEvents is called with all relevant events added in the update. + AddEvents([]Event) error + // AddSiacoinElements is called with all new siacoin elements in the + // update. Ephemeral siacoin elements are not included. + AddSiacoinElements([]SiacoinElement) error + // RemoveSiacoinElements is called with all siacoin elements that were + // spent in the update. + RemoveSiacoinElements([]types.SiacoinOutputID) error + } + + // RevertTx is an interface for atomically reverting a chain update from a + // single address wallet. + RevertTx interface { + // WalletStateElements returns all state elements in the database. It is used + // to update the proofs of all state elements affected by the update. + WalletStateElements() ([]types.StateElement, error) + // UpdateStateElements updates the proofs of all state elements affected by the + // update. + UpdateStateElements([]types.StateElement) error + + // RevertIndex is called with the chain index that is being reverted. + // Any transactions and siacoin elements that were created by the index + // should be removed. + RevertIndex(types.ChainIndex) error + // AddSiacoinElements is called with all siacoin elements that are + // now unspent due to the revert. + AddSiacoinElements([]SiacoinElement) error + } +) + +// addressPayoutEvents is a helper to add all payout transactions from an +// apply update to a slice of transactions. +func addressPayoutEvents(addr types.Address, cau *chain.ApplyUpdate) (events []Event) { + index := cau.State.Index + state := cau.State + block := cau.Block + + // cache the source of new immature outputs to show payout transactions + if state.FoundationPrimaryAddress == addr { + events = append(events, Event{ + ID: types.Hash256(index.ID.FoundationOutputID()), + Index: index, + Transaction: types.Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + state.FoundationSubsidy(), + }, + }, + Inflow: state.FoundationSubsidy().Value, + Source: EventSourceFoundationPayout, + Timestamp: block.Timestamp, + }) + } + + // add the miner payouts + for i := range block.MinerPayouts { + if block.MinerPayouts[i].Address != addr { + continue + } + + events = append(events, Event{ + ID: types.Hash256(index.ID.MinerOutputID(i)), + Index: index, + Transaction: types.Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + block.MinerPayouts[i], + }, + }, + Inflow: block.MinerPayouts[i].Value, + MaturityHeight: state.MaturityHeight(), + Source: EventSourceMinerPayout, + Timestamp: block.Timestamp, + }) + } + + // add the file contract outputs + cau.ForEachFileContractElement(func(fce types.FileContractElement, rev *types.FileContractElement, resolved bool, valid bool) { + if !resolved { + return + } + + if valid { + for i, output := range fce.FileContract.ValidProofOutputs { + if output.Address != addr { + continue + } + + outputID := types.FileContractID(fce.ID).ValidOutputID(i) + events = append(events, Event{ + ID: types.Hash256(outputID), + Index: index, + Transaction: types.Transaction{ + SiacoinOutputs: []types.SiacoinOutput{output}, + FileContracts: []types.FileContract{fce.FileContract}, + }, + Inflow: fce.FileContract.ValidProofOutputs[i].Value, + MaturityHeight: state.MaturityHeight(), + Source: EventSourceValidContract, + Timestamp: block.Timestamp, + }) + } + } else { + for i, output := range fce.FileContract.MissedProofOutputs { + if output.Address != addr { + continue + } + + outputID := types.FileContractID(fce.ID).MissedOutputID(i) + events = append(events, Event{ + ID: types.Hash256(outputID), + Index: index, + Transaction: types.Transaction{ + SiacoinOutputs: []types.SiacoinOutput{output}, + FileContracts: []types.FileContract{fce.FileContract}, + }, + Inflow: fce.FileContract.ValidProofOutputs[i].Value, + MaturityHeight: state.MaturityHeight(), + Source: EventSourceMissedContract, + Timestamp: block.Timestamp, + }) + } + } + }) + return +} + +// ApplyChainUpdates atomically applies a batch of wallet updates +func ApplyChainUpdates(tx ApplyTx, address types.Address, updates []*chain.ApplyUpdate) error { + stateElements, err := tx.WalletStateElements() + if err != nil { + return fmt.Errorf("failed to get state elements: %w", err) + } + + var events []Event + var spentUTXOs []types.SiacoinOutputID + newUTXOs := make(map[types.Hash256]SiacoinElement) + + for _, cau := range updates { + events = append(events, addressPayoutEvents(address, cau)...) + utxoValues := make(map[types.SiacoinOutputID]types.Currency) + + cau.ForEachSiacoinElement(func(se types.SiacoinElement, spent bool) { + if se.SiacoinOutput.Address != address { + return + } + + // cache the value of the utxo to use when calculating outflow + utxoValues[types.SiacoinOutputID(se.ID)] = se.SiacoinOutput.Value + if spent { + // remove the utxo from the new utxos + delete(newUTXOs, se.ID) + + // skip ephemeral outputs + if se.StateElement.LeafIndex != types.EphemeralLeafIndex { + spentUTXOs = append(spentUTXOs, types.SiacoinOutputID(se.ID)) + } + } else { + newUTXOs[se.ID] = SiacoinElement{ + SiacoinElement: se, + Index: cau.State.Index, + } + } + }) + + for _, txn := range cau.Block.Transactions { + ev := Event{ + ID: types.Hash256(txn.ID()), + Index: cau.State.Index, + Transaction: txn, + Source: EventSourceTransaction, + MaturityHeight: cau.State.Index.Height, // regular transactions "mature" immediately + Timestamp: cau.Block.Timestamp, + } + + for _, si := range txn.SiacoinInputs { + if si.UnlockConditions.UnlockHash() == address { + value, ok := utxoValues[si.ParentID] + if !ok { + panic("missing utxo") // this should never happen + } + ev.Inflow = ev.Inflow.Add(value) + } + } + + for _, so := range txn.SiacoinOutputs { + if so.Address != address { + continue + } + ev.Outflow = ev.Outflow.Add(so.Value) + } + + // skip irrelevant transactions + if ev.Inflow.IsZero() && ev.Outflow.IsZero() { + continue + } + + events = append(events, ev) + } + + for i := range stateElements { + cau.UpdateElementProof(&stateElements[i]) + } + + for _, se := range newUTXOs { + cau.UpdateElementProof(&se.StateElement) + } + } + + createdUTXOs := make([]SiacoinElement, 0, len(newUTXOs)) + for _, se := range newUTXOs { + createdUTXOs = append(createdUTXOs, se) + } + + if err := tx.AddSiacoinElements(createdUTXOs); err != nil { + return fmt.Errorf("failed to add siacoin elements: %w", err) + } else if err := tx.RemoveSiacoinElements(spentUTXOs); err != nil { + return fmt.Errorf("failed to remove siacoin elements: %w", err) + } else if err := tx.AddEvents(events); err != nil { + return fmt.Errorf("failed to add events: %w", err) + } else if err := tx.UpdateStateElements(stateElements); err != nil { + return fmt.Errorf("failed to update state elements: %w", err) + } + return nil +} + +// RevertChainUpdate atomically reverts a chain update from a wallet +func RevertChainUpdate(tx RevertTx, address types.Address, cru *chain.RevertUpdate) error { + stateElements, err := tx.WalletStateElements() + if err != nil { + return fmt.Errorf("failed to get state elements: %w", err) + } + + var readdedUTXOs []SiacoinElement + + cru.ForEachSiacoinElement(func(se types.SiacoinElement, spent bool) { + if se.SiacoinOutput.Address != address { + return + } + + if !spent { + readdedUTXOs = append(readdedUTXOs, SiacoinElement{ + SiacoinElement: se, + Index: cru.State.Index, + }) + } + }) + + for i := range stateElements { + cru.UpdateElementProof(&stateElements[i]) + } + + if err := tx.RevertIndex(cru.State.Index); err != nil { + return fmt.Errorf("failed to revert block: %w", err) + } else if err := tx.AddSiacoinElements(readdedUTXOs); err != nil { + return fmt.Errorf("failed to add siacoin elements: %w", err) + } else if err := tx.UpdateStateElements(stateElements); err != nil { + return fmt.Errorf("failed to update state elements: %w", err) + } + return nil +} diff --git a/wallet/wallet.go b/wallet/wallet.go index cefd1df..f5ab36d 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -17,11 +17,12 @@ import ( // either be created by sending Siacoins between unlock hashes or they can be // created by consensus (e.g. a miner payout, a siafund claim, or a contract). const ( - TxnSourceTransaction TransactionSource = "transaction" - TxnSourceMinerPayout TransactionSource = "miner" - TxnSourceSiafundClaim TransactionSource = "siafundClaim" - TxnSourceContract TransactionSource = "contract" - TxnSourceFoundationPayout TransactionSource = "foundation" + EventSourceTransaction EventSource = "transaction" + EventSourceMinerPayout EventSource = "miner" + EventSourceSiafundClaim EventSource = "siafundClaim" + EventSourceValidContract EventSource = "validContract" + EventSourceMissedContract EventSource = "missedContract" + EventSourceFoundationPayout EventSource = "foundation" ) const ( @@ -41,19 +42,34 @@ var ( ) type ( - // A TransactionSource is a string indicating the source of a transaction. - TransactionSource string + // An EventSource is a string indicating the source of a transaction. + EventSource string - // A Transaction is a transaction relevant to a particular wallet, paired - // with useful metadata. - Transaction struct { - ID types.TransactionID `json:"id"` - Index types.ChainIndex `json:"index"` - Transaction types.Transaction `json:"transaction"` - Inflow types.Currency `json:"inflow"` - Outflow types.Currency `json:"outflow"` - Source TransactionSource `json:"source"` - Timestamp time.Time `json:"timestamp"` + // An Event is a transaction or other event that affects the wallet including + // miner payouts, siafund claims, and file contract payouts. + Event struct { + ID types.Hash256 `json:"id"` + Index types.ChainIndex `json:"index"` + Inflow types.Currency `json:"inflow"` + Outflow types.Currency `json:"outflow"` + Transaction types.Transaction `json:"transaction"` + Source EventSource `json:"source"` + MaturityHeight uint64 `json:"maturityHeight"` + Timestamp time.Time `json:"timestamp"` + } + + // Balance is the balance of a wallet. + Balance struct { + Spendable types.Currency `json:"spendable"` + Confirmed types.Currency `json:"confirmed"` + Unconfirmed types.Currency `json:"unconfirmed"` + Immature types.Currency `json:"immature"` + } + + // A SiacoinElement is a siacoin output paired with its chain index + SiacoinElement struct { + types.SiacoinElement + Index types.ChainIndex `json:"index"` } // A ChainManager manages the current state of the blockchain. @@ -76,14 +92,15 @@ type ( // the last wallet change. Tip() (types.ChainIndex, error) // UnspentSiacoinElements returns a list of all unspent siacoin outputs - UnspentSiacoinElements() ([]types.SiacoinElement, error) - // Transactions returns a paginated list of transactions ordered by + // including immature outputs. + UnspentSiacoinElements() ([]SiacoinElement, error) + // WalletEvents returns a paginated list of transactions ordered by // maturity height, descending. If no more transactions are available, // (nil, nil) should be returned. - Transactions(limit, offset int) ([]Transaction, error) - // TransactionCount returns the total number of transactions in the + WalletEvents(offset, limit int) ([]Event, error) + // WalletEventCount returns the total number of events relevant to the // wallet. - TransactionCount() (uint64, error) + WalletEventCount() (uint64, error) } // A SingleAddressWallet is a hot wallet that manages the outputs controlled @@ -111,7 +128,7 @@ type ( var ErrDifferentSeed = errors.New("seed differs from wallet seed") // EncodeTo implements types.EncoderTo. -func (t Transaction) EncodeTo(e *types.Encoder) { +func (t Event) EncodeTo(e *types.Encoder) { t.ID.EncodeTo(e) t.Index.EncodeTo(e) t.Transaction.EncodeTo(e) @@ -122,13 +139,13 @@ func (t Transaction) EncodeTo(e *types.Encoder) { } // DecodeFrom implements types.DecoderFrom. -func (t *Transaction) DecodeFrom(d *types.Decoder) { +func (t *Event) DecodeFrom(d *types.Decoder) { t.ID.DecodeFrom(d) t.Index.DecodeFrom(d) t.Transaction.DecodeFrom(d) (*types.V2Currency)(&t.Inflow).DecodeFrom(d) (*types.V2Currency)(&t.Outflow).DecodeFrom(d) - t.Source = TransactionSource(d.ReadString()) + t.Source = EventSource(d.ReadString()) t.Timestamp = d.ReadTime() } @@ -149,10 +166,10 @@ func (sw *SingleAddressWallet) UnlockConditions() types.UnlockConditions { } // Balance returns the balance of the wallet. -func (sw *SingleAddressWallet) Balance() (spendable, confirmed, unconfirmed types.Currency, err error) { +func (sw *SingleAddressWallet) Balance() (balance Balance, err error) { outputs, err := sw.store.UnspentSiacoinElements() if err != nil { - return types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, fmt.Errorf("failed to get unspent outputs: %w", err) + return Balance{}, fmt.Errorf("failed to get unspent outputs: %w", err) } tpoolSpent := make(map[types.Hash256]bool) @@ -178,35 +195,39 @@ func (sw *SingleAddressWallet) Balance() (spendable, confirmed, unconfirmed type sw.mu.Lock() defer sw.mu.Unlock() + bh := sw.cm.TipState().Index.Height for _, sco := range outputs { - confirmed = confirmed.Add(sco.SiacoinOutput.Value) - if time.Now().After(sw.locked[sco.ID]) && !tpoolSpent[sco.ID] { - spendable = spendable.Add(sco.SiacoinOutput.Value) + if sco.MaturityHeight > bh { + balance.Immature = balance.Immature.Add(sco.SiacoinOutput.Value) + } else { + balance.Confirmed = balance.Confirmed.Add(sco.SiacoinOutput.Value) + if time.Now().After(sw.locked[sco.ID]) && !tpoolSpent[sco.ID] { + balance.Spendable = balance.Spendable.Add(sco.SiacoinOutput.Value) + } } } for _, sco := range tpoolUtxos { - unconfirmed = unconfirmed.Add(sco.SiacoinOutput.Value) + balance.Unconfirmed = balance.Unconfirmed.Add(sco.SiacoinOutput.Value) } return } -// Transactions returns a paginated list of transactions, ordered by block -// height descending. If no more transactions are available, (nil, nil) is -// returned. -func (sw *SingleAddressWallet) Transactions(limit, offset int) ([]Transaction, error) { - return sw.store.Transactions(limit, offset) +// Events returns a paginated list of events, ordered by maturity height, descending. +// If no more events are available, (nil, nil) is returned. +func (sw *SingleAddressWallet) Events(offset, limit int) ([]Event, error) { + return sw.store.WalletEvents(offset, limit) } -// TransactionCount returns the total number of transactions in the wallet. -func (sw *SingleAddressWallet) TransactionCount() (uint64, error) { - return sw.store.TransactionCount() +// EventCount returns the total number of events relevant to the wallet. +func (sw *SingleAddressWallet) EventCount() (uint64, error) { + return sw.store.WalletEventCount() } // SpendableOutputs returns a list of spendable siacoin outputs, a spendable // output is an unspent output that's not locked, not currently in the // transaction pool and that has matured. -func (sw *SingleAddressWallet) SpendableOutputs() ([]types.SiacoinElement, error) { +func (sw *SingleAddressWallet) SpendableOutputs() ([]SiacoinElement, error) { // fetch outputs from the store utxos, err := sw.store.UnspentSiacoinElements() if err != nil { @@ -248,7 +269,7 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty return nil, nil } - utxos, err := sw.store.UnspentSiacoinElements() + elements, err := sw.store.UnspentSiacoinElements() if err != nil { return nil, err } @@ -274,14 +295,13 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty defer sw.mu.Unlock() // remove locked and spent outputs - filtered := utxos[:0] - for _, sce := range utxos { + utxos := make([]types.SiacoinElement, 0, len(elements)) + for _, sce := range elements { if time.Now().Before(sw.locked[sce.ID]) || tpoolSpent[sce.ID] { continue } - filtered = append(filtered, sce) + utxos = append(utxos, sce.SiacoinElement) } - utxos = filtered // sort by value, descending sort.Slice(utxos, func(i, j int) bool { @@ -402,7 +422,7 @@ func (sw *SingleAddressWallet) Tip() (types.ChainIndex, error) { // UnconfirmedTransactions returns all unconfirmed transactions relevant to the // wallet. -func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Transaction, error) { +func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Event, error) { confirmed, err := sw.store.UnspentSiacoinElements() if err != nil { return nil, fmt.Errorf("failed to get unspent outputs: %w", err) @@ -415,12 +435,12 @@ func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Transaction, error) poolTxns := sw.cm.PoolTransactions() - var annotated []Transaction + var annotated []Event for _, txn := range poolTxns { - wt := Transaction{ - ID: txn.ID(), + wt := Event{ + ID: types.Hash256(txn.ID()), Transaction: txn, - Source: TxnSourceTransaction, + Source: EventSourceTransaction, Timestamp: time.Now(), } @@ -451,7 +471,7 @@ func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Transaction, error) // outputs. It also returns a list of output IDs that need to be signed. func (sw *SingleAddressWallet) Redistribute(outputs int, amount, feePerByte types.Currency) (txns []types.Transaction, toSign []types.Hash256, err error) { // fetch outputs from the store - utxos, err := sw.store.UnspentSiacoinElements() + elements, err := sw.store.UnspentSiacoinElements() if err != nil { return nil, nil, err } @@ -473,8 +493,8 @@ func (sw *SingleAddressWallet) Redistribute(outputs int, amount, feePerByte type // adjust the number of desired outputs for any output we encounter that is // unused, matured and has the same value - usable := utxos[:0] - for _, sce := range utxos { + utxos := make([]types.SiacoinElement, 0, len(elements)) + for _, sce := range elements { inUse := time.Now().After(sw.locked[sce.ID]) || inPool[sce.ID] matured := bh >= sce.MaturityHeight sameValue := sce.SiacoinOutput.Value.Equals(amount) @@ -486,10 +506,9 @@ func (sw *SingleAddressWallet) Redistribute(outputs int, amount, feePerByte type // collect usable outputs for defragging if !inUse && matured && !sameValue { - usable = append(usable, sce) + utxos = append(utxos, sce.SiacoinElement) } } - utxos = usable // return early if we don't have to defrag at all if outputs <= 0 { diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 7063da6..d2ecf5e 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -2,6 +2,7 @@ package wallet_test import ( "errors" + "fmt" "testing" "go.sia.tech/core/types" @@ -11,6 +12,24 @@ import ( "go.uber.org/zap/zaptest" ) +// check balance is a helper function that compares the wallet's balance to +// the expected values. +func checkBalance(w *wallet.SingleAddressWallet, spendable, confirmed, immature, unconfirmed types.Currency) error { + balance, err := w.Balance() + if err != nil { + return fmt.Errorf("failed to get balance: %w", err) + } else if !balance.Confirmed.Equals(confirmed) { + return fmt.Errorf("expected %v confirmed balance, got %v", confirmed, balance.Confirmed) + } else if !balance.Spendable.Equals(spendable) { + return fmt.Errorf("expected %v spendable balance, got %v", spendable, balance.Spendable) + } else if !balance.Unconfirmed.Equals(unconfirmed) { + return fmt.Errorf("expected %v unconfirmed balance, got %v", unconfirmed, balance.Unconfirmed) + } else if !balance.Immature.Equals(immature) { + return fmt.Errorf("expected %v immature balance, got %v", immature, balance.Immature) + } + return nil +} + func TestWallet(t *testing.T) { log := zaptest.NewLogger(t) @@ -36,15 +55,8 @@ func TestWallet(t *testing.T) { } defer w.Close() - spendable, confirmed, unconfirmed, err := w.Balance() - if err != nil { + if err := checkBalance(w, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency); err != nil { t.Fatal(err) - } else if !confirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected zero confirmed balance, got %v", confirmed) - } else if !spendable.Equals(types.ZeroCurrency) { - t.Fatalf("expected zero spendable balance, got %v", spendable) - } else if !unconfirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected zero unconfirmed balance, got %v", unconfirmed) } initialReward := cm.TipState().BlockReward() @@ -54,6 +66,23 @@ func TestWallet(t *testing.T) { t.Fatal(err) } + maturityHeight := cm.TipState().MaturityHeight() + // check that the wallet has a single event + if events, err := w.Events(0, 100); err != nil { + t.Fatal(err) + } else if len(events) != 1 { + t.Fatalf("expected 1 event, got %v", len(events)) + } else if events[0].Source != wallet.EventSourceMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Source) + } else if events[0].MaturityHeight != maturityHeight { + t.Fatalf("expected maturity height %v, got %v", maturityHeight, events[0].MaturityHeight) + } + + // check that the wallet has an immature balance + if checkBalance(w, types.ZeroCurrency, types.ZeroCurrency, initialReward, types.ZeroCurrency); err != nil { + t.Fatal(err) + } + // mine until the payout matures tip := cm.TipState() target := tip.MaturityHeight() + 1 @@ -65,19 +94,12 @@ func TestWallet(t *testing.T) { } // check that one payout has matured - spendable, confirmed, unconfirmed, err = w.Balance() - if err != nil { + if checkBalance(w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency); err != nil { t.Fatal(err) - } else if !confirmed.Equals(initialReward) { - t.Fatalf("expected %v confirmed balance, got %v", initialReward, confirmed) - } else if !spendable.Equals(initialReward) { - t.Fatalf("expected %v spendable balance, got %v", initialReward, spendable) - } else if !unconfirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v unconfirmed balance, got %v", types.ZeroCurrency, unconfirmed) } - // check that the wallet has a single transaction - count, err := w.TransactionCount() + // check that the wallet has a single event + count, err := w.EventCount() if err != nil { t.Fatal(err) } else if count != 1 { @@ -85,13 +107,13 @@ func TestWallet(t *testing.T) { } // check that the payout transaction was created - txns, err := w.Transactions(100, 0) + events, err := w.Events(0, 100) if err != nil { t.Fatal(err) - } else if len(txns) != 1 { - t.Fatalf("expected 1 transaction, got %v", len(txns)) - } else if txns[0].Source != wallet.TxnSourceMinerPayout { - t.Fatalf("expected miner payout, got %v", txns[0].Source) + } else if len(events) != 1 { + t.Fatalf("expected 1 transaction, got %v", len(events)) + } else if events[0].Source != wallet.EventSourceMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Source) } // split the wallet's balance into 20 outputs @@ -113,15 +135,8 @@ func TestWallet(t *testing.T) { w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true}) // check that wallet now has no spendable balance - spendable, confirmed, unconfirmed, err = w.Balance() - if err != nil { + if err := checkBalance(w, types.ZeroCurrency, initialReward, types.ZeroCurrency, types.ZeroCurrency); err != nil { t.Fatal(err) - } else if !confirmed.Equals(initialReward) { - t.Fatalf("expected %v confirmed balance, got %v", types.ZeroCurrency, confirmed) - } else if !spendable.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v spendable balance, got %v", types.ZeroCurrency, spendable) - } else if !unconfirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v unconfirmed balance, got %v", types.ZeroCurrency, unconfirmed) } // check the wallet has no unconfirmed transactions @@ -145,7 +160,7 @@ func TestWallet(t *testing.T) { t.Fatalf("expected 1 unconfirmed transaction, got %v", len(poolTxns)) } else if poolTxns[0].Transaction.ID() != txn.ID() { t.Fatalf("expected transaction %v, got %v", txn.ID(), poolTxns[0].Transaction.ID()) - } else if poolTxns[0].Source != wallet.TxnSourceTransaction { + } else if poolTxns[0].Source != wallet.EventSourceTransaction { t.Fatalf("expected wallet source, got %v", poolTxns[0].Source) } else if !poolTxns[0].Inflow.Equals(initialReward) { t.Fatalf("expected %v inflow, got %v", initialReward, poolTxns[0].Inflow) @@ -153,17 +168,11 @@ func TestWallet(t *testing.T) { t.Fatalf("expected %v outflow, got %v", types.ZeroCurrency, poolTxns[0].Outflow) } - // check that the wallet has an unconfirmed balance - // check that wallet now has no spendable balance - spendable, confirmed, unconfirmed, err = w.Balance() - if err != nil { + // check that the wallet now has an unconfirmed balance + // note: the wallet should still have a "confirmed" balance since the pool + // transaction is not yet confirmed. + if err := checkBalance(w, types.ZeroCurrency, initialReward, types.ZeroCurrency, initialReward); err != nil { t.Fatal(err) - } else if !confirmed.Equals(initialReward) { - t.Fatalf("expected %v confirmed balance, got %v", types.ZeroCurrency, confirmed) - } else if !spendable.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v spendable balance, got %v", types.ZeroCurrency, spendable) - } else if !unconfirmed.Equals(initialReward) { - t.Fatalf("expected %v unconfirmed balance, got %v", initialReward, unconfirmed) } // mine a block to confirm the transaction @@ -172,20 +181,13 @@ func TestWallet(t *testing.T) { t.Fatal(err) } - // check that the balance was confirmed - spendable, confirmed, unconfirmed, err = w.Balance() - if err != nil { + // check that the balance was confirmed and the other values reset + if err := checkBalance(w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency); err != nil { t.Fatal(err) - } else if !confirmed.Equals(initialReward) { - t.Fatalf("expected %v confirmed balance, got %v", initialReward, confirmed) - } else if !spendable.Equals(initialReward) { - t.Fatalf("expected %v spendable balance, got %v", initialReward, spendable) - } else if !unconfirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v unconfirmed balance, got %v", types.ZeroCurrency, unconfirmed) } - // check that the wallet has two transactions. - count, err = w.TransactionCount() + // check that the wallet has two events + count, err = w.EventCount() if err != nil { t.Fatal(err) } else if count != 2 { @@ -193,13 +195,15 @@ func TestWallet(t *testing.T) { } // check that the paginated transactions are in the proper order - txns, err = w.Transactions(100, 0) + events, err = w.Events(0, 100) if err != nil { t.Fatal(err) - } else if len(txns) != 2 { - t.Fatalf("expected 2 transactions, got %v", len(txns)) - } else if len(txns[0].Transaction.SiacoinOutputs) != 20 { - t.Fatalf("expected 20 outputs, got %v", len(txns[0].Transaction.SiacoinOutputs)) + } else if len(events) != 2 { + t.Fatalf("expected 2 transactions, got %v", len(events)) + } else if events[0].ID != types.Hash256(txn.ID()) { + t.Fatalf("expected transaction %v, got %v", txn.ID(), events[1].ID) + } else if len(events[0].Transaction.SiacoinOutputs) != 20 { + t.Fatalf("expected 20 outputs, got %v", len(events[1].Transaction.SiacoinOutputs)) } // send all the outputs to the burn address individually @@ -209,7 +213,6 @@ func TestWallet(t *testing.T) { sent[i].SiacoinOutputs = []types.SiacoinOutput{ {Address: types.VoidAddress, Value: sendAmount}, } - toSign, err := w.FundTransaction(&sent[i], sendAmount, false) if err != nil { t.Fatal(err) @@ -229,37 +232,31 @@ func TestWallet(t *testing.T) { // check that the wallet now has 22 transactions, the initial payout // transaction, the split transaction, and 20 void transactions - count, err = w.TransactionCount() + count, err = w.EventCount() if err != nil { t.Fatal(err) } else if count != 22 { t.Fatalf("expected 22 transactions, got %v", count) } - // check that the wallet's balance is 0 - // check that wallet now has no spendable balance - spendable, confirmed, unconfirmed, err = w.Balance() - if err != nil { + // check that all the wallet balances have reset + if err := checkBalance(w, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency); err != nil { t.Fatal(err) - } else if !confirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v confirmed balance, got %v", types.ZeroCurrency, confirmed) - } else if !spendable.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v spendable balance, got %v", types.ZeroCurrency, spendable) - } else if !unconfirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v unconfirmed balance, got %v", types.ZeroCurrency, unconfirmed) } // check that the paginated transactions are in the proper order - txns, err = w.Transactions(20, 0) // limit of 20 so the original two transactions are not included + events, err = w.Events(0, 20) // limit of 20 so the original two transactions are not included if err != nil { t.Fatal(err) - } else if len(txns) != 20 { - t.Fatalf("expected 20 transactions, got %v", len(txns)) + } else if len(events) != 20 { + t.Fatalf("expected 20 transactions, got %v", len(events)) } for i := range sent { - j := len(txns) - i - 1 // transactions are received in reverse order - if txns[j].ID != sent[i].ID() { - t.Fatalf("expected transaction %v, got %v", sent[i].ID(), txns[i].ID) + // events should be chronologically ordered, reverse the order they + // were added to the transaction pool + j := len(events) - i - 1 + if events[j].ID != types.Hash256(sent[i].ID()) { + t.Fatalf("expected transaction %v, got %v", sent[i].ID(), events[i].ID) } } } @@ -289,15 +286,9 @@ func TestWalletUnconfirmed(t *testing.T) { } defer w.Close() - spendable, confirmed, unconfirmed, err := w.Balance() - if err != nil { + // check that the wallet has no balance + if err := checkBalance(w, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency); err != nil { t.Fatal(err) - } else if !confirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected zero confirmed balance, got %v", confirmed) - } else if !spendable.Equals(types.ZeroCurrency) { - t.Fatalf("expected zero spendable balance, got %v", spendable) - } else if !unconfirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected zero unconfirmed balance, got %v", unconfirmed) } initialReward := cm.TipState().BlockReward() @@ -307,6 +298,11 @@ func TestWalletUnconfirmed(t *testing.T) { t.Fatal(err) } + // check that the wallet has an immature balance + if err := checkBalance(w, types.ZeroCurrency, types.ZeroCurrency, initialReward, types.ZeroCurrency); err != nil { + t.Fatal(err) + } + // mine until the payout matures tip := cm.TipState() target := tip.MaturityHeight() + 1 @@ -318,15 +314,8 @@ func TestWalletUnconfirmed(t *testing.T) { } // check that one payout has matured - spendable, confirmed, unconfirmed, err = w.Balance() - if err != nil { + if err := checkBalance(w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency); err != nil { t.Fatal(err) - } else if !confirmed.Equals(initialReward) { - t.Fatalf("expected %v confirmed balance, got %v", initialReward, confirmed) - } else if !spendable.Equals(initialReward) { - t.Fatalf("expected %v spendable balance, got %v", initialReward, spendable) - } else if !unconfirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v unconfirmed balance, got %v", types.ZeroCurrency, unconfirmed) } // fund and sign a transaction sending half the balance to the burn address @@ -344,17 +333,9 @@ func TestWalletUnconfirmed(t *testing.T) { w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true}) // check that wallet now has no spendable balance - spendable, confirmed, unconfirmed, err = w.Balance() - if err != nil { + if err := checkBalance(w, types.ZeroCurrency, initialReward, types.ZeroCurrency, types.ZeroCurrency); err != nil { t.Fatal(err) - } else if !confirmed.Equals(initialReward) { - t.Fatalf("expected %v confirmed balance, got %v", initialReward, confirmed) - } else if !spendable.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v spendable balance, got %v", types.ZeroCurrency, spendable) - } else if !unconfirmed.Equals(types.ZeroCurrency) { - t.Fatalf("expected %v unconfirmed balance, got %v", types.ZeroCurrency, unconfirmed) } - // add the transaction to the pool if _, err := cm.AddPoolTransactions([]types.Transaction{txn}); err != nil { t.Fatal(err)