From d0138bda0d66f8385647297ff2c9e6e65247d046 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Fri, 12 Jan 2024 15:01:19 -0800 Subject: [PATCH] wallet: add useUnconfirmed to FundTransaction --- testutil/network.go | 13 ++-- wallet/wallet.go | 65 +++++++++++++---- wallet/wallet_test.go | 162 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 201 insertions(+), 39 deletions(-) diff --git a/testutil/network.go b/testutil/network.go index a2c2170..f34ab23 100644 --- a/testutil/network.go +++ b/testutil/network.go @@ -23,15 +23,16 @@ func Network() (*consensus.Network, types.Block) { } // MineBlock mines a block with the given transactions. -func MineBlock(cs consensus.State, transactions []types.Transaction, minerAddress types.Address) types.Block { +func MineBlock(cm *chain.Manager, minerAddress types.Address) types.Block { + state := cm.TipState() b := types.Block{ - ParentID: cs.Index.ID, + ParentID: state.Index.ID, Timestamp: types.CurrentTimestamp(), - Transactions: transactions, - MinerPayouts: []types.SiacoinOutput{{Address: minerAddress, Value: cs.BlockReward()}}, + Transactions: cm.PoolTransactions(), + MinerPayouts: []types.SiacoinOutput{{Address: minerAddress, Value: state.BlockReward()}}, } - for b.ID().CmpWork(cs.ChildTarget) < 0 { - b.Nonce += cs.NonceFactor() + for b.ID().CmpWork(state.ChildTarget) < 0 { + b.Nonce += state.NonceFactor() } return b } diff --git a/wallet/wallet.go b/wallet/wallet.go index 3b8527b..b7f1627 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -48,6 +48,7 @@ type ( // A ChainManager manages the current state of the blockchain. ChainManager interface { + TipState() consensus.State BestIndex(height uint64) (types.ChainIndex, bool) PoolTransactions() []types.Transaction @@ -152,6 +153,10 @@ func (sw *SingleAddressWallet) Balance() (spendable, confirmed, unconfirmed type delete(tpoolUtxos, types.Hash256(sci.ParentID)) } for i, sco := range txn.SiacoinOutputs { + if sco.Address != sw.addr { + continue + } + tpoolUtxos[types.Hash256(txn.SiacoinOutputID(i))] = types.SiacoinElement{ StateElement: types.StateElement{ ID: types.Hash256(types.SiacoinOutputID(txn.SiacoinOutputID(i))), @@ -192,7 +197,7 @@ func (sw *SingleAddressWallet) TransactionCount() (uint64, error) { // transaction. If necessary, a change output will also be added. The inputs // will not be available to future calls to FundTransaction unless ReleaseInputs // is called. -func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount types.Currency) ([]types.Hash256, func(), error) { +func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount types.Currency, useUnconfirmed bool) ([]types.Hash256, func(), error) { if amount.IsZero() { return nil, func() {}, nil } @@ -223,41 +228,70 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty } // remove locked and spent outputs - usableUTXOs := utxos[:0] + filtered := utxos[:0] for _, sce := range utxos { if time.Now().Before(sw.locked[sce.ID]) || tpoolSpent[sce.ID] { continue } - usableUTXOs = append(usableUTXOs, sce) + filtered = append(filtered, sce) } + utxos = filtered // sort by value, descending - sort.Slice(usableUTXOs, func(i, j int) bool { - return usableUTXOs[i].SiacoinOutput.Value.Cmp(usableUTXOs[j].SiacoinOutput.Value) > 0 + sort.Slice(utxos, func(i, j int) bool { + return utxos[i].SiacoinOutput.Value.Cmp(utxos[j].SiacoinOutput.Value) > 0 }) + var unconfirmedUTXOs []types.SiacoinElement + if useUnconfirmed { + for _, sce := range tpoolUtxos { + if sce.SiacoinOutput.Address != sw.addr || time.Now().Before(sw.locked[sce.ID]) { + continue + } + unconfirmedUTXOs = append(unconfirmedUTXOs, sce) + } + + // sort by value, descending + sort.Slice(unconfirmedUTXOs, func(i, j int) bool { + return unconfirmedUTXOs[i].SiacoinOutput.Value.Cmp(unconfirmedUTXOs[j].SiacoinOutput.Value) > 0 + }) + } + // fund the transaction using the largest utxos first var selected []types.SiacoinElement var inputSum types.Currency - for i, sce := range usableUTXOs { + for i, sce := range utxos { if inputSum.Cmp(amount) >= 0 { - usableUTXOs = usableUTXOs[i:] + utxos = utxos[i:] break } selected = append(selected, sce) inputSum = inputSum.Add(sce.SiacoinOutput.Value) } - // if the transaction can't be funded, return an error - if inputSum.Cmp(amount) < 0 { + if inputSum.Cmp(amount) < 0 && useUnconfirmed { + // try adding unconfirmed utxos. + for _, sce := range unconfirmedUTXOs { + if inputSum.Cmp(amount) >= 0 { + break + } + selected = append(selected, sce) + inputSum = inputSum.Add(sce.SiacoinOutput.Value) + } + + if inputSum.Cmp(amount) < 0 { + // still not enough funds + return nil, nil, ErrNotEnoughFunds + } + } else if inputSum.Cmp(amount) < 0 { return nil, nil, ErrNotEnoughFunds } // check if remaining utxos should be defragged txnInputs := len(txn.SiacoinInputs) + len(selected) - if len(usableUTXOs) > sw.cfg.DefragThreshold && txnInputs < sw.cfg.MaxInputsForDefrag { + if len(utxos) > sw.cfg.DefragThreshold && txnInputs < sw.cfg.MaxInputsForDefrag { // add the smallest utxos to the transaction - defraggable := usableUTXOs + defraggable := utxos if len(defraggable) > sw.cfg.MaxDefragUTXOs { defraggable = defraggable[len(defraggable)-sw.cfg.MaxDefragUTXOs:] } @@ -302,13 +336,15 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty } // SignTransaction adds a signature to each of the specified inputs. -func (sw *SingleAddressWallet) SignTransaction(cs consensus.State, txn *types.Transaction, toSign []types.Hash256, cf types.CoveredFields) error { +func (sw *SingleAddressWallet) SignTransaction(txn *types.Transaction, toSign []types.Hash256, cf types.CoveredFields) { + state := sw.cm.TipState() + for _, id := range toSign { var h types.Hash256 if cf.WholeTransaction { - h = cs.WholeSigHash(*txn, id, 0, 0, cf.Signatures) + h = state.WholeSigHash(*txn, id, 0, 0, cf.Signatures) } else { - h = cs.PartialSigHash(*txn, cf) + h = state.PartialSigHash(*txn, cf) } sig := sw.priv.SignHash(h) txn.Signatures = append(txn.Signatures, types.TransactionSignature{ @@ -318,7 +354,6 @@ func (sw *SingleAddressWallet) SignTransaction(cs consensus.State, txn *types.Tr Signature: sig[:], }) } - return nil } // Tip returns the block height the wallet has scanned to. diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 593b3cd..d7786c1 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -1,6 +1,7 @@ package wallet_test import ( + "errors" "testing" "go.sia.tech/core/chain" @@ -47,22 +48,20 @@ func TestWallet(t *testing.T) { } initialReward := cm.TipState().BlockReward() - tip := cm.TipState() // mine a block to fund the wallet - b := testutil.MineBlock(tip, nil, w.Address()) + b := testutil.MineBlock(cm, w.Address()) if err := cm.AddBlocks([]types.Block{b}); err != nil { t.Fatal(err) } - tip = cm.TipState() // mine until the payout matures + tip := cm.TipState() target := tip.MaturityHeight() + 1 for i := tip.Index.Height; i < target; i++ { - b := testutil.MineBlock(tip, nil, types.VoidAddress) + b := testutil.MineBlock(cm, types.VoidAddress) if err := cm.AddBlocks([]types.Block{b}); err != nil { t.Fatal(err) } - tip = cm.TipState() } // check that one payout has matured @@ -107,15 +106,12 @@ func TestWallet(t *testing.T) { } // fund and sign the transaction - toSign, release, err := w.FundTransaction(&txn, initialReward) + toSign, release, err := w.FundTransaction(&txn, initialReward, false) if err != nil { t.Fatal(err) } defer release() - - if err := w.SignTransaction(tip, &txn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { - t.Fatal(err) - } + w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true}) // check that wallet now has no spendable balance spendable, confirmed, unconfirmed, err = w.Balance() @@ -172,8 +168,7 @@ func TestWallet(t *testing.T) { } // mine a block to confirm the transaction - tip = cm.TipState() - b = testutil.MineBlock(tip, []types.Transaction{txn}, types.VoidAddress) + b = testutil.MineBlock(cm, types.VoidAddress) if err := cm.AddBlocks([]types.Block{b}); err != nil { t.Fatal(err) } @@ -216,19 +211,20 @@ func TestWallet(t *testing.T) { {Address: types.VoidAddress, Value: sendAmount}, } - toSign, release, err := w.FundTransaction(&sent[i], sendAmount) + toSign, release, err := w.FundTransaction(&sent[i], sendAmount, false) if err != nil { t.Fatal(err) } defer release() + w.SignTransaction(&sent[i], toSign, types.CoveredFields{WholeTransaction: true}) + } - if err := w.SignTransaction(tip, &sent[i], toSign, types.CoveredFields{WholeTransaction: true}); err != nil { - t.Fatal(err) - } + // add the transactions to the pool + if _, err := cm.AddPoolTransactions(sent); err != nil { + t.Fatal(err) } - tip = cm.TipState() - b = testutil.MineBlock(tip, sent, types.VoidAddress) + b = testutil.MineBlock(cm, types.VoidAddress) if err := cm.AddBlocks([]types.Block{b}); err != nil { t.Fatal(err) } @@ -269,3 +265,133 @@ func TestWallet(t *testing.T) { } } } + +func TestWalletUnconfirmed(t *testing.T) { + log := zaptest.NewLogger(t) + + network, genesis := testutil.Network() + + cs, tipState, err := chain.NewDBStore(chain.NewMemDB(), network, genesis) + if err != nil { + t.Fatal(err) + } + + cm := chain.NewManager(cs, tipState) + + pk := types.GeneratePrivateKey() + ws := testutil.NewEphemeralWalletStore(pk) + + if err := cm.AddSubscriber(ws, types.ChainIndex{}); err != nil { + t.Fatal(err) + } + + w, err := wallet.NewSingleAddressWallet(pk, cm, ws, wallet.WithLogger(log.Named("wallet"))) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + spendable, confirmed, unconfirmed, err := w.Balance() + if 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() + // mine a block to fund the wallet + b := testutil.MineBlock(cm, w.Address()) + if err := cm.AddBlocks([]types.Block{b}); err != nil { + t.Fatal(err) + } + + // mine until the payout matures + tip := cm.TipState() + target := tip.MaturityHeight() + 1 + for i := tip.Index.Height; i < target; i++ { + b := testutil.MineBlock(cm, types.VoidAddress) + if err := cm.AddBlocks([]types.Block{b}); err != nil { + t.Fatal(err) + } + } + + // check that one payout has matured + spendable, confirmed, unconfirmed, err = w.Balance() + if 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 + txn := types.Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + {Address: types.VoidAddress, Value: initialReward.Div64(2)}, + {Address: w.Address(), Value: initialReward.Div64(2)}, + }, + } + + toSign, release, err := w.FundTransaction(&txn, initialReward, false) + if err != nil { + t.Fatal(err) + } + defer release() + 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 { + 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) + } + + // check that the wallet has one unconfirmed transaction + poolTxns, err := w.UnconfirmedTransactions() + if err != nil { + t.Fatal(err) + } else if len(poolTxns) != 1 { + t.Fatal("expected 1 unconfirmed transaction") + } + + txn2 := types.Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + {Address: types.VoidAddress, Value: initialReward.Div64(2)}, + }, + } + + // try to send a new transaction without using the unconfirmed output + _, _, err = w.FundTransaction(&txn2, initialReward.Div64(2), false) + if !errors.Is(err, wallet.ErrNotEnoughFunds) { + t.Fatalf("expected funding error with no usable utxos, got %v", err) + } + + toSign, release, err = w.FundTransaction(&txn2, initialReward.Div64(2), true) + if err != nil { + t.Fatal(err) + } + defer release() + w.SignTransaction(&txn2, toSign, types.CoveredFields{WholeTransaction: true}) + + // broadcast the transaction + if _, err := cm.AddPoolTransactions([]types.Transaction{txn, txn2}); err != nil { + t.Fatal(err) + } +}