diff --git a/chain/manager_test.go b/chain/manager_test.go index d90639d..b19fd15 100644 --- a/chain/manager_test.go +++ b/chain/manager_test.go @@ -1,7 +1,6 @@ package chain import ( - "fmt" "reflect" "testing" @@ -94,7 +93,6 @@ func TestManager(t *testing.T) { path = nil for _, ru := range rus { path = append(path, ru.State.Index.Height) - fmt.Println(path) } for _, au := range aus { path = append(path, au.State.Index.Height) diff --git a/testutil/wallet.go b/testutil/wallet.go index 963dda0..e1fc4a3 100644 --- a/testutil/wallet.go +++ b/testutil/wallet.go @@ -44,11 +44,6 @@ func (et *ephemeralWalletUpdateTxn) UpdateStateElements(elements []types.StateEl return nil } -func (et *ephemeralWalletUpdateTxn) AddEvents(events []wallet.Event) error { - et.store.events = append(events, et.store.events...) - return nil -} - func (et *ephemeralWalletUpdateTxn) ApplyIndex(index types.ChainIndex, created, spent []types.SiacoinElement, events []wallet.Event) error { for _, se := range spent { if _, ok := et.store.utxos[types.SiacoinOutputID(se.ID)]; !ok { @@ -137,6 +132,7 @@ func (es *EphemeralWalletStore) UnspentSiacoinElements() (utxos []types.SiacoinE defer es.mu.Unlock() for _, se := range es.utxos { + se.MerkleProof = append([]types.Hash256(nil), se.MerkleProof...) utxos = append(utxos, se) } return utxos, nil diff --git a/wallet/events.go b/wallet/events.go new file mode 100644 index 0000000..662b789 --- /dev/null +++ b/wallet/events.go @@ -0,0 +1,81 @@ +package wallet + +import ( + "time" + + "go.sia.tech/core/types" +) + +// event types indicate the source of an event. Events can +// either be created by sending Siacoins between addresses or they can be +// created by consensus (e.g. a miner payout, a siafund claim, or a contract). +const ( + EventTypeMinerPayout = "miner" + EventTypeFoundationSubsidy = "foundation" + + EventTypeV1Transaction = "v1Transaction" + EventTypeV1Contract = "v1Contract" + + EventTypeV2Transaction = "v2Transaction" + EventTypeV2Contract = "v2Contract" +) + +type ( + // An EventMinerPayout represents a miner payout from a block. + EventMinerPayout struct { + SiacoinElement types.SiacoinElement `json:"siacoinElement"` + } + + // EventFoundationSubsidy represents a foundation subsidy from a block. + EventFoundationSubsidy struct { + SiacoinElement types.SiacoinElement `json:"siacoinElement"` + } + + // An EventV1ContractPayout represents a file contract payout from a v1 + // contract. + EventV1ContractPayout struct { + FileContract types.FileContractElement `json:"fileContract"` + SiacoinElement types.SiacoinElement `json:"siacoinElement"` + Missed bool `json:"missed"` + } + + // An EventV2ContractPayout represents a file contract payout from a v2 + // contract. + EventV2ContractPayout struct { + FileContract types.V2FileContractElement `json:"fileContract"` + Resolution types.V2FileContractResolutionType `json:"resolution"` + SiacoinElement types.SiacoinElement `json:"siacoinElement"` + Missed bool `json:"missed"` + } + + // EventV1Transaction is a transaction event that includes the transaction + EventV1Transaction types.Transaction + + // EventV2Transaction is a transaction event that includes the transaction + EventV2Transaction types.V2Transaction + + // EventData contains the data associated with an event. + EventData interface { + isEvent() bool + } + + // 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"` + Type string `json:"type"` + Data EventData `json:"data"` + MaturityHeight uint64 `json:"maturityHeight"` + Timestamp time.Time `json:"timestamp"` + } +) + +func (EventMinerPayout) isEvent() bool { return true } +func (EventFoundationSubsidy) isEvent() bool { return true } +func (EventV1ContractPayout) isEvent() bool { return true } +func (EventV2ContractPayout) isEvent() bool { return true } +func (EventV1Transaction) isEvent() bool { return true } +func (EventV2Transaction) isEvent() bool { return true } diff --git a/wallet/update.go b/wallet/update.go index 387bd4e..cd4ed54 100644 --- a/wallet/update.go +++ b/wallet/update.go @@ -8,6 +8,15 @@ import ( ) type ( + // A ChainUpdate is an interface for iterating over the elements in a chain + // update. + ChainUpdate interface { + ForEachSiacoinElement(func(sce types.SiacoinElement, spent bool)) + ForEachSiafundElement(func(sfe types.SiafundElement, spent bool)) + ForEachFileContractElement(func(fce types.FileContractElement, rev *types.FileContractElement, resolved, valid bool)) + ForEachV2FileContractElement(func(fce types.V2FileContractElement, rev *types.V2FileContractElement, res types.V2FileContractResolutionType)) + } + // UpdateTx is an interface for atomically applying chain updates to a // single address wallet. UpdateTx interface { @@ -36,98 +45,208 @@ type ( } ) -// 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 +// appliedEvents returns a slice of events that are relevant to the wallet +// in the chain update. +func appliedEvents(cau chain.ApplyUpdate, walletAddress types.Address) (events []Event) { + cs := cau.State block := cau.Block + index := cs.Index + maturityHeight := cs.MaturityHeight() + siacoinElements := make(map[types.SiacoinOutputID]types.SiacoinElement) - // 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, + // cache the value of spent siacoin elements to use when calculating outflow + cau.ForEachSiacoinElement(func(se types.SiacoinElement, spent bool) { + if spent { + siacoinElements[types.SiacoinOutputID(se.ID)] = se + } + }) + + addEvent := func(id types.Hash256, data EventData) { + ev := Event{ + ID: id, + Index: index, + Data: data, Timestamp: block.Timestamp, - }) + } + + switch data := data.(type) { + case EventMinerPayout: + ev.Inflow = data.SiacoinElement.SiacoinOutput.Value + ev.Type = EventTypeMinerPayout + ev.MaturityHeight = maturityHeight + case EventFoundationSubsidy: + ev.Inflow = data.SiacoinElement.SiacoinOutput.Value + ev.Type = EventTypeFoundationSubsidy + ev.MaturityHeight = maturityHeight + case EventV1Transaction: + for _, si := range data.SiacoinInputs { + if si.UnlockConditions.UnlockHash() == walletAddress { + ev.Outflow = ev.Outflow.Add(siacoinElements[si.ParentID].SiacoinOutput.Value) + } + } + + for _, so := range data.SiacoinOutputs { + if so.Address == walletAddress { + ev.Inflow = ev.Inflow.Add(so.Value) + } + } + ev.MaturityHeight = index.Height + ev.Type = EventTypeV1Transaction + case EventV1ContractPayout: + ev.Inflow = data.SiacoinElement.SiacoinOutput.Value + ev.Type = EventTypeV1Contract + ev.MaturityHeight = cs.MaturityHeight() + case EventV2Transaction: + for _, si := range data.SiacoinInputs { + if si.SatisfiedPolicy.Policy.Address() == walletAddress { + ev.Outflow = ev.Outflow.Add(siacoinElements[types.SiacoinOutputID(si.Parent.ID)].SiacoinOutput.Value) + } + } + + for _, so := range data.SiacoinOutputs { + if so.Address == walletAddress { + ev.Inflow = ev.Inflow.Add(so.Value) + } + } + ev.Type = EventTypeV2Transaction + ev.MaturityHeight = index.Height + case EventV2ContractPayout: + ev.Inflow = data.SiacoinElement.SiacoinOutput.Value + ev.Type = EventTypeV2Contract + ev.MaturityHeight = cs.MaturityHeight() + } + + events = append(events, ev) + } + + relevantV1Txn := func(txn types.Transaction) bool { + for _, so := range txn.SiacoinOutputs { + if so.Address == walletAddress { + return true + } + } + for _, si := range txn.SiacoinInputs { + if si.UnlockConditions.UnlockHash() == walletAddress { + return true + } + } + return false } - // add the miner payouts - for i := range block.MinerPayouts { - if block.MinerPayouts[i].Address != addr { + for _, txn := range block.Transactions { + if !relevantV1Txn(txn) { continue } + addEvent(types.Hash256(txn.ID()), EventV1Transaction(txn)) + } - 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, - }) + relevantV2Txn := func(txn types.V2Transaction) bool { + for _, so := range txn.SiacoinOutputs { + if so.Address == walletAddress { + return true + } + } + for _, si := range txn.SiacoinInputs { + if si.Parent.SiacoinOutput.Address == walletAddress { + return true + } + } + return false + } + + for _, txn := range block.V2Transactions() { + if !relevantV2Txn(txn) { + continue + } + addEvent(types.Hash256(txn.ID()), EventV2Transaction(txn)) } - // 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 { + for i, so := range fce.FileContract.ValidProofOutputs { + if so.Address != walletAddress { 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, + addEvent(types.Hash256(types.FileContractID(fce.ID).ValidOutputID(0)), EventV1ContractPayout{ + FileContract: fce, + SiacoinElement: siacoinElements[outputID], + Missed: false, }) } } else { - for i, output := range fce.FileContract.MissedProofOutputs { - if output.Address != addr { + for i, so := range fce.FileContract.MissedProofOutputs { + if so.Address != walletAddress { 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, + addEvent(types.Hash256(types.FileContractID(fce.ID).MissedOutputID(0)), EventV1ContractPayout{ + FileContract: fce, + SiacoinElement: siacoinElements[outputID], + Missed: true, }) } } }) + + cau.ForEachV2FileContractElement(func(fce types.V2FileContractElement, rev *types.V2FileContractElement, res types.V2FileContractResolutionType) { + if res == nil { + return + } + + var missed bool + if _, ok := res.(*types.V2FileContractExpiration); ok { + missed = true + } + + if fce.V2FileContract.HostOutput.Address == walletAddress { + outputID := types.FileContractID(fce.ID).V2HostOutputID() + addEvent(types.Hash256(outputID), EventV2ContractPayout{ + FileContract: fce, + Resolution: res, + SiacoinElement: siacoinElements[outputID], + Missed: missed, + }) + } + + if fce.V2FileContract.RenterOutput.Address == walletAddress { + outputID := types.FileContractID(fce.ID).V2RenterOutputID() + addEvent(types.Hash256(outputID), EventV2ContractPayout{ + FileContract: fce, + Resolution: res, + SiacoinElement: siacoinElements[outputID], + Missed: missed, + }) + } + }) + + blockID := block.ID() + for i, so := range block.MinerPayouts { + if so.Address != walletAddress { + continue + } + + outputID := blockID.MinerOutputID(i) + addEvent(types.Hash256(outputID), EventMinerPayout{ + SiacoinElement: siacoinElements[outputID], + }) + } + + outputID := blockID.FoundationOutputID() + se, ok := siacoinElements[outputID] + if !ok || se.SiacoinOutput.Address != walletAddress { + return + } + addEvent(types.Hash256(outputID), EventFoundationSubsidy{ + SiacoinElement: se, + }) + return } @@ -160,7 +279,6 @@ func applyChainState(tx UpdateTx, address types.Address, cau chain.ApplyUpdate) } var createdUTXOs, spentUTXOs []types.SiacoinElement - events := addressPayoutEvents(address, cau) utxoValues := make(map[types.SiacoinOutputID]types.Currency) cau.ForEachSiacoinElement(func(se types.SiacoinElement, spent bool) { @@ -183,46 +301,11 @@ func applyChainState(tx UpdateTx, address types.Address, cau chain.ApplyUpdate) } }) - 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]) } - if err := tx.ApplyIndex(cau.State.Index, createdUTXOs, spentUTXOs, events); err != nil { + if err := tx.ApplyIndex(cau.State.Index, createdUTXOs, spentUTXOs, appliedEvents(cau, address)); err != nil { return fmt.Errorf("failed to apply index: %w", err) } else if err := tx.UpdateStateElements(stateElements); err != nil { return fmt.Errorf("failed to update state elements: %w", err) diff --git a/wallet/wallet.go b/wallet/wallet.go index 60a94d4..d016ae1 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -13,18 +13,6 @@ import ( "go.uber.org/zap" ) -// transaction sources indicate the source of a transaction. Transactions can -// 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 ( - EventSourceTransaction EventSource = "transaction" - EventSourceMinerPayout EventSource = "miner" - EventSourceSiafundClaim EventSource = "siafundClaim" - EventSourceValidContract EventSource = "validContract" - EventSourceMissedContract EventSource = "missedContract" - EventSourceFoundationPayout EventSource = "foundation" -) - const ( // bytesPerInput is the encoded size of a SiacoinInput and corresponding // TransactionSignature, assuming standard UnlockConditions. @@ -42,22 +30,6 @@ var ( ) type ( - // An EventSource is a string indicating the source of a transaction. - EventSource string - - // 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"` @@ -71,6 +43,7 @@ type ( TipState() consensus.State BestIndex(height uint64) (types.ChainIndex, bool) PoolTransactions() []types.Transaction + V2PoolTransactions() []types.V2Transaction OnReorg(func(types.ChainIndex)) func() } @@ -119,28 +92,6 @@ type ( // NewSingleAddressWallet than was used to initialize the wallet var ErrDifferentSeed = errors.New("seed differs from wallet seed") -// EncodeTo implements types.EncoderTo. -func (t Event) EncodeTo(e *types.Encoder) { - t.ID.EncodeTo(e) - t.Index.EncodeTo(e) - t.Transaction.EncodeTo(e) - types.V2Currency(t.Inflow).EncodeTo(e) - types.V2Currency(t.Outflow).EncodeTo(e) - e.WriteString(string(t.Source)) - e.WriteTime(t.Timestamp) -} - -// DecodeFrom implements types.DecoderFrom. -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 = EventSource(d.ReadString()) - t.Timestamp = d.ReadTime() -} - // Close closes the wallet func (sw *SingleAddressWallet) Close() error { // TODO: remove subscription?? @@ -176,9 +127,31 @@ func (sw *SingleAddressWallet) Balance() (balance Balance, err error) { continue } - tpoolUtxos[types.Hash256(txn.SiacoinOutputID(i))] = types.SiacoinElement{ + outputID := txn.SiacoinOutputID(i) + tpoolUtxos[types.Hash256(outputID)] = types.SiacoinElement{ StateElement: types.StateElement{ - ID: types.Hash256(types.SiacoinOutputID(txn.SiacoinOutputID(i))), + ID: types.Hash256(types.SiacoinOutputID(outputID)), + }, + SiacoinOutput: sco, + } + } + } + + for _, txn := range sw.cm.V2PoolTransactions() { + for _, si := range txn.SiacoinInputs { + tpoolSpent[types.Hash256(si.Parent.ID)] = true + delete(tpoolUtxos, types.Hash256(si.Parent.ID)) + } + txnID := txn.ID() + for i, sco := range txn.SiacoinOutputs { + if sco.Address != sw.addr { + continue + } + + outputID := txn.SiacoinOutputID(txnID, i) + tpoolUtxos[types.Hash256(outputID)] = types.SiacoinElement{ + StateElement: types.StateElement{ + ID: types.Hash256(types.SiacoinOutputID(outputID)), }, SiacoinOutput: sco, } @@ -252,18 +225,14 @@ func (sw *SingleAddressWallet) SpendableOutputs() ([]types.SiacoinElement, error return unspent, nil } -// FundTransaction adds siacoin inputs worth at least amount to the provided -// 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, useUnconfirmed bool) ([]types.Hash256, error) { +func (sw *SingleAddressWallet) selectUTXOs(amount types.Currency, inputs int, useUnconfirmed bool) ([]types.SiacoinElement, types.Currency, error) { if amount.IsZero() { - return nil, nil + return nil, types.ZeroCurrency, nil } elements, err := sw.store.UnspentSiacoinElements() if err != nil { - return nil, err + return nil, types.ZeroCurrency, err } tpoolSpent := make(map[types.Hash256]bool) @@ -283,9 +252,6 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty } } - sw.mu.Lock() - defer sw.mu.Unlock() - // remove immature, locked and spent outputs cs := sw.cm.TipState() utxos := make([]types.SiacoinElement, 0, len(elements)) @@ -348,14 +314,14 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty if inputSum.Cmp(amount) < 0 { // still not enough funds - return nil, fmt.Errorf("%w: inputs %v < needed %v (used: %v immature: %v unconfirmed: %v)", ErrNotEnoughFunds, inputSum.String(), amount.String(), usedSum.String(), immatureSum.String(), unconfirmedSum.String()) + return nil, types.ZeroCurrency, fmt.Errorf("%w: inputs %v < needed %v (used: %v immature: %v unconfirmed: %v)", ErrNotEnoughFunds, inputSum.String(), amount.String(), usedSum.String(), immatureSum.String(), unconfirmedSum.String()) } } else if inputSum.Cmp(amount) < 0 { - return nil, fmt.Errorf("%w: inputs %v < needed %v (used: %v immature: %v", ErrNotEnoughFunds, inputSum.String(), amount.String(), usedSum.String(), immatureSum.String()) + return nil, types.ZeroCurrency, fmt.Errorf("%w: inputs %v < needed %v (used: %v immature: %v", ErrNotEnoughFunds, inputSum.String(), amount.String(), usedSum.String(), immatureSum.String()) } // check if remaining utxos should be defragged - txnInputs := len(txn.SiacoinInputs) + len(selected) + txnInputs := inputs + len(selected) if len(utxos) > sw.cfg.DefragThreshold && txnInputs < sw.cfg.MaxInputsForDefrag { // add the smallest utxos to the transaction defraggable := utxos @@ -373,6 +339,25 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty txnInputs++ } } + return selected, inputSum, nil +} + +// FundTransaction adds siacoin inputs worth at least amount to the provided +// 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, useUnconfirmed bool) ([]types.Hash256, error) { + if amount.IsZero() { + return nil, nil + } + + sw.mu.Lock() + defer sw.mu.Unlock() + + selected, inputSum, err := sw.selectUTXOs(amount, len(txn.SiacoinInputs), useUnconfirmed) + if err != nil { + return nil, err + } // add a change output if necessary if inputSum.Cmp(amount) > 0 { @@ -397,6 +382,9 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty // SignTransaction adds a signature to each of the specified inputs. func (sw *SingleAddressWallet) SignTransaction(txn *types.Transaction, toSign []types.Hash256, cf types.CoveredFields) { + sw.mu.Lock() + defer sw.mu.Unlock() + state := sw.cm.TipState() for _, id := range toSign { @@ -416,14 +404,83 @@ func (sw *SingleAddressWallet) SignTransaction(txn *types.Transaction, toSign [] } } +// FundV2Transaction adds siacoin inputs worth at least amount to the provided +// 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. +// +// The returned consensus state should be used to calculate the input signature +// hash and as the basis for AddV2PoolTransactions. +func (sw *SingleAddressWallet) FundV2Transaction(txn *types.V2Transaction, amount types.Currency, useUnconfirmed bool) (consensus.State, []int, error) { + if amount.IsZero() { + return sw.cm.TipState(), nil, nil + } + + sw.mu.Lock() + defer sw.mu.Unlock() + + selected, inputSum, err := sw.selectUTXOs(amount, len(txn.SiacoinInputs), useUnconfirmed) + if err != nil { + return consensus.State{}, nil, err + } + + // add a change output if necessary + if inputSum.Cmp(amount) > 0 { + txn.SiacoinOutputs = append(txn.SiacoinOutputs, types.SiacoinOutput{ + Value: inputSum.Sub(amount), + Address: sw.addr, + }) + } + + toSign := make([]int, 0, len(selected)) + for _, sce := range selected { + toSign = append(toSign, len(txn.SiacoinInputs)) + txn.SiacoinInputs = append(txn.SiacoinInputs, types.V2SiacoinInput{ + Parent: sce, + }) + sw.locked[sce.ID] = time.Now().Add(sw.cfg.ReservationDuration) + } + + return sw.cm.TipState(), toSign, nil +} + +// SignV2Inputs adds a signature to each of the specified siacoin inputs. +func (sw *SingleAddressWallet) SignV2Inputs(state consensus.State, txn *types.V2Transaction, toSign []int) { + if len(toSign) == 0 { + return + } + + sw.mu.Lock() + defer sw.mu.Unlock() + + policy := sw.SpendPolicy() + sigHash := state.InputSigHash(*txn) + for _, i := range toSign { + txn.SiacoinInputs[i].SatisfiedPolicy = types.SatisfiedPolicy{ + Policy: policy, + Signatures: []types.Signature{sw.SignHash(sigHash)}, + } + } +} + // Tip returns the block height the wallet has scanned to. func (sw *SingleAddressWallet) Tip() (types.ChainIndex, error) { return sw.store.Tip() } +// SpendPolicy returns the wallet's default spend policy. +func (sw *SingleAddressWallet) SpendPolicy() types.SpendPolicy { + return types.SpendPolicy{Type: types.PolicyTypeUnlockConditions(sw.UnlockConditions())} +} + +// SignHash signs the hash with the wallet's private key. +func (sw *SingleAddressWallet) SignHash(h types.Hash256) types.Signature { + return sw.priv.SignHash(h) +} + // UnconfirmedTransactions returns all unconfirmed transactions relevant to the // wallet. -func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Event, error) { +func (sw *SingleAddressWallet) UnconfirmedTransactions() (annotated []Event, err error) { confirmed, err := sw.store.UnspentSiacoinElements() if err != nil { return nil, fmt.Errorf("failed to get unspent outputs: %w", err) @@ -434,35 +491,70 @@ func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Event, error) { utxos[types.Hash256(se.ID)] = se.SiacoinOutput } - poolTxns := sw.cm.PoolTransactions() + index := types.ChainIndex{ + Height: sw.cm.TipState().Index.Height + 1, + } + timestamp := time.Now().Truncate(time.Second) - var annotated []Event - for _, txn := range poolTxns { - wt := Event{ - ID: types.Hash256(txn.ID()), - Transaction: txn, - Source: EventSourceTransaction, - Timestamp: time.Now(), + addEvent := func(id types.Hash256, eventType string, data EventData, inflow, outflow types.Currency) { + ev := Event{ + ID: id, + Index: index, + MaturityHeight: index.Height, + Timestamp: timestamp, + Inflow: inflow, + Outflow: outflow, + Type: eventType, + Data: data, } + annotated = append(annotated, ev) + } + for _, txn := range sw.cm.PoolTransactions() { + var inflow, outflow types.Currency for _, sci := range txn.SiacoinInputs { if sco, ok := utxos[types.Hash256(sci.ParentID)]; ok { - wt.Outflow = wt.Outflow.Add(sco.Value) + outflow = outflow.Add(sco.Value) } } for i, sco := range txn.SiacoinOutputs { if sco.Address == sw.addr { - wt.Inflow = wt.Inflow.Add(sco.Value) + inflow = inflow.Add(sco.Value) utxos[types.Hash256(txn.SiacoinOutputID(i))] = sco } } - if wt.Inflow.IsZero() && wt.Outflow.IsZero() { + // skip transactions that don't affect the wallet + if inflow.IsZero() && outflow.IsZero() { + continue + } + + addEvent(types.Hash256(txn.ID()), EventTypeV1Transaction, EventV1Transaction(txn), inflow, outflow) + } + + for _, txn := range sw.cm.V2PoolTransactions() { + var inflow, outflow types.Currency + for _, sci := range txn.SiacoinInputs { + if sci.Parent.SiacoinOutput.Address != sw.addr { + continue + } + outflow = outflow.Add(sci.Parent.SiacoinOutput.Value) + } + + for _, sco := range txn.SiacoinOutputs { + if sco.Address != sw.addr { + continue + } + inflow = inflow.Add(sco.Value) + } + + // skip transactions that don't affect the wallet + if inflow.IsZero() && outflow.IsZero() { continue } - annotated = append(annotated, wt) + addEvent(types.Hash256(txn.ID()), EventTypeV2Transaction, EventV2Transaction(txn), inflow, outflow) } return annotated, nil } @@ -594,7 +686,7 @@ func (sw *SingleAddressWallet) Redistribute(outputs int, amount, feePerByte type // ReleaseInputs is a helper function that releases the inputs of txn for use in // other transactions. It should only be called on transactions that are invalid // or will never be broadcast. -func (sw *SingleAddressWallet) ReleaseInputs(txns ...types.Transaction) { +func (sw *SingleAddressWallet) ReleaseInputs(txns []types.Transaction, v2txns []types.V2Transaction) { sw.mu.Lock() defer sw.mu.Unlock() for _, txn := range txns { @@ -602,6 +694,11 @@ func (sw *SingleAddressWallet) ReleaseInputs(txns ...types.Transaction) { delete(sw.locked, types.Hash256(in.ParentID)) } } + for _, txn := range v2txns { + for _, in := range txn.SiacoinInputs { + delete(sw.locked, in.Parent.ID) + } + } } // isLocked returns true if the siacoin output with given id is locked, this diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 7742204..d285afa 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "go.sia.tech/core/consensus" "go.sia.tech/core/types" "go.sia.tech/coreutils" "go.sia.tech/coreutils/chain" @@ -34,17 +35,21 @@ func syncDB(cm *chain.Manager, store wallet.SingleAddressStore) error { } } -func mineAndSync(cm *chain.Manager, ws wallet.SingleAddressStore, address types.Address, n uint64) error { +func mineAndSync(t *testing.T, cm *chain.Manager, ws wallet.SingleAddressStore, address types.Address, n uint64) { + t.Helper() + // mine n blocks for i := uint64(0); i < n; i++ { if block, found := coreutils.MineBlock(cm, address, 5*time.Second); !found { - panic("failed to mine block") + t.Fatal("failed to mine block") } else if err := cm.AddBlocks([]types.Block{block}); err != nil { - return fmt.Errorf("failed to add blocks: %w", err) + t.Fatal(err) } } // wait for the wallet to sync - return syncDB(cm, ws) + if err := syncDB(cm, ws); err != nil { + t.Fatal(err) + } } // assertBalance compares the wallet's balance to the expected values. @@ -91,9 +96,7 @@ func TestWallet(t *testing.T) { assertBalance(t, w, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency) // mine a block to fund the wallet - if err := mineAndSync(cm, ws, w.Address(), 1); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, ws, w.Address(), 1) maturityHeight := cm.TipState().MaturityHeight() // check that the wallet has a single event @@ -101,8 +104,8 @@ func TestWallet(t *testing.T) { 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].Type != wallet.EventTypeMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Type) } else if events[0].MaturityHeight != maturityHeight { t.Fatalf("expected maturity height %v, got %v", maturityHeight, events[0].MaturityHeight) } @@ -131,9 +134,7 @@ func TestWallet(t *testing.T) { // mine until the payout matures tip := cm.TipState() target := tip.MaturityHeight() - if err := mineAndSync(cm, ws, types.VoidAddress, target-tip.Index.Height); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, ws, types.VoidAddress, target-tip.Index.Height) // check that one payout has matured assertBalance(t, w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency) @@ -152,8 +153,8 @@ func TestWallet(t *testing.T) { t.Fatal(err) } 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) + } else if events[0].Type != wallet.EventTypeMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Type) } // fund and sign the transaction @@ -185,10 +186,10 @@ func TestWallet(t *testing.T) { t.Fatal(err) } else if len(poolTxns) != 1 { 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.EventSourceTransaction { - t.Fatalf("expected wallet source, got %v", poolTxns[0].Source) + } else if poolTxns[0].ID != types.Hash256(txn.ID()) { + t.Fatalf("expected transaction %v, got %v", txn.ID(), poolTxns[0].ID) + } else if poolTxns[0].Type != wallet.EventTypeV1Transaction { + t.Fatalf("expected wallet source, got %v", poolTxns[0].Type) } else if !poolTxns[0].Inflow.Equals(initialReward) { t.Fatalf("expected %v inflow, got %v", initialReward, poolTxns[0].Inflow) } else if !poolTxns[0].Outflow.Equals(initialReward) { @@ -200,9 +201,7 @@ func TestWallet(t *testing.T) { // transaction is not yet confirmed. assertBalance(t, w, types.ZeroCurrency, initialReward, types.ZeroCurrency, initialReward) // mine a block to confirm the transaction - if err := mineAndSync(cm, ws, types.VoidAddress, 1); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, ws, types.VoidAddress, 1) // check that the balance was confirmed and the other values reset assertBalance(t, w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency) @@ -223,8 +222,8 @@ func TestWallet(t *testing.T) { 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)) + } else if n := len((events[0].Data.(wallet.EventV1Transaction)).SiacoinOutputs); n != 20 { + t.Fatalf("expected 20 outputs, got %v", n) } // send all the outputs to the burn address individually @@ -244,9 +243,8 @@ func TestWallet(t *testing.T) { // add the transactions to the pool if _, err := cm.AddPoolTransactions(sent); err != nil { t.Fatal(err) - } else if err := mineAndSync(cm, ws, types.VoidAddress, 1); err != nil { - t.Fatal(err) } + mineAndSync(t, cm, ws, types.VoidAddress, 1) // check that the wallet now has 22 transactions, the initial payout // transaction, the split transaction, and 20 void transactions @@ -300,11 +298,8 @@ func TestWalletUnconfirmed(t *testing.T) { defer w.Close() // fund the wallet - if err := mineAndSync(cm, ws, w.Address(), 1); err != nil { - t.Fatal(err) - } else if err := mineAndSync(cm, ws, types.VoidAddress, cm.TipState().MaturityHeight()-1); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, ws, w.Address(), 1) + mineAndSync(t, cm, ws, types.VoidAddress, cm.TipState().MaturityHeight()-1) // check that one payout has matured initialReward := cm.TipState().BlockReward() @@ -387,11 +382,8 @@ func TestWalletRedistribute(t *testing.T) { defer w.Close() // fund the wallet - if err := mineAndSync(cm, ws, w.Address(), 1); err != nil { - t.Fatal(err) - } else if err := mineAndSync(cm, ws, types.VoidAddress, cm.TipState().MaturityHeight()-1); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, ws, w.Address(), 1) + mineAndSync(t, cm, ws, types.VoidAddress, cm.TipState().MaturityHeight()-1) redistribute := func(amount types.Currency, n int) error { txns, toSign, err := w.Redistribute(n, amount, types.ZeroCurrency) @@ -406,9 +398,8 @@ func TestWalletRedistribute(t *testing.T) { } if _, err := cm.AddPoolTransactions(txns); err != nil { return fmt.Errorf("failed to add transactions to pool: %w", err) - } else if err := mineAndSync(cm, ws, types.VoidAddress, 1); err != nil { - return fmt.Errorf("failed to mine and sync: %w", err) } + mineAndSync(t, cm, ws, types.VoidAddress, 1) return nil } @@ -488,9 +479,7 @@ func TestReorg(t *testing.T) { assertBalance(t, w, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency) // mine a block to fund the wallet - if err := mineAndSync(cm, ws, w.Address(), 1); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, ws, w.Address(), 1) maturityHeight := cm.TipState().MaturityHeight() // check that the wallet has a single event @@ -498,8 +487,8 @@ func TestReorg(t *testing.T) { 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].Type != wallet.EventTypeMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Type) } else if events[0].MaturityHeight != maturityHeight { t.Fatalf("expected maturity height %v, got %v", maturityHeight, events[0].MaturityHeight) } @@ -528,9 +517,7 @@ func TestReorg(t *testing.T) { // mine until the payout matures tip := cm.TipState() target := tip.MaturityHeight() - if err := mineAndSync(cm, ws, types.VoidAddress, target-tip.Index.Height); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, ws, types.VoidAddress, target-tip.Index.Height) // check that one payout has matured assertBalance(t, w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency) @@ -549,8 +536,8 @@ func TestReorg(t *testing.T) { t.Fatal(err) } 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) + } else if events[0].Type != wallet.EventTypeMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Type) } // fund and sign the transaction @@ -582,10 +569,10 @@ func TestReorg(t *testing.T) { t.Fatal(err) } else if len(poolTxns) != 1 { 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.EventSourceTransaction { - t.Fatalf("expected wallet source, got %v", poolTxns[0].Source) + } else if poolTxns[0].ID != types.Hash256(txn.ID()) { + t.Fatalf("expected transaction %v, got %v", txn.ID(), poolTxns[0].ID) + } else if poolTxns[0].Type != wallet.EventTypeV1Transaction { + t.Fatalf("expected wallet source, got %v", poolTxns[0].Type) } else if !poolTxns[0].Inflow.Equals(initialReward) { t.Fatalf("expected %v inflow, got %v", initialReward, poolTxns[0].Inflow) } else if !poolTxns[0].Outflow.Equals(initialReward) { @@ -597,9 +584,7 @@ func TestReorg(t *testing.T) { // transaction is not yet confirmed. assertBalance(t, w, types.ZeroCurrency, initialReward, types.ZeroCurrency, initialReward) // mine a block to confirm the transaction - if err := mineAndSync(cm, ws, types.VoidAddress, 1); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, ws, types.VoidAddress, 1) rollbackState := cm.TipState() // check that the balance was confirmed and the other values reset @@ -621,8 +606,8 @@ func TestReorg(t *testing.T) { 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)) + } else if n := len((events[0].Data.(wallet.EventV1Transaction)).SiacoinOutputs); n != 20 { + t.Fatalf("expected 20 outputs, got %v", n) } txn2 := types.Transaction{ @@ -637,7 +622,7 @@ func TestReorg(t *testing.T) { w.SignTransaction(&txn2, toSign, types.CoveredFields{WholeTransaction: true}) // release the inputs to construct a double spend - w.ReleaseInputs(txn2) + w.ReleaseInputs([]types.Transaction{txn2}, nil) txn1 := types.Transaction{ SiacoinOutputs: []types.SiacoinOutput{ @@ -653,9 +638,8 @@ func TestReorg(t *testing.T) { // add the first transaction to the pool if _, err := cm.AddPoolTransactions([]types.Transaction{txn1}); err != nil { t.Fatal(err) - } else if err := mineAndSync(cm, ws, types.VoidAddress, 1); err != nil { - t.Fatal(err) } + mineAndSync(t, cm, ws, types.VoidAddress, 1) // check that the wallet now has 3 transactions: the initial payout // transaction, the split transaction, and a void transaction @@ -729,7 +713,510 @@ func TestReorg(t *testing.T) { t.Fatalf("expected transaction %v, got %v", txn2.ID(), events[0].ID) } else if events[1].ID != types.Hash256(txn.ID()) { // split transaction second t.Fatalf("expected transaction %v, got %v", txn.ID(), events[1].ID) - } else if events[2].Source != wallet.EventSourceMinerPayout { // payout transaction last - t.Fatalf("expected miner payout, got %v", events[0].Source) + } else if events[2].Type != wallet.EventTypeMinerPayout { // payout transaction last + t.Fatalf("expected miner payout, got %v", events[0].Type) + } +} + +func TestWalletV2(t *testing.T) { + // create wallet store + pk := types.GeneratePrivateKey() + ws := testutil.NewEphemeralWalletStore(pk) + + // create chain store + network, genesis := testutil.Network() + cs, tipState, err := chain.NewDBStore(chain.NewMemDB(), network, genesis) + if err != nil { + t.Fatal(err) + } + + // create chain manager and subscribe the wallet + cm := chain.NewManager(cs, tipState) + // create wallet + l := zaptest.NewLogger(t) + w, err := wallet.NewSingleAddressWallet(pk, cm, ws, wallet.WithLogger(l.Named("wallet"))) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + // check balance + assertBalance(t, w, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency) + + // mine a block to fund the wallet + mineAndSync(t, cm, ws, w.Address(), 1) + 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].Type != wallet.EventTypeMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Type) + } 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 + initialReward := cm.TipState().BlockReward() + assertBalance(t, w, types.ZeroCurrency, types.ZeroCurrency, initialReward, types.ZeroCurrency) + + // create a transaction that splits the wallet's balance into 20 outputs + txn := types.Transaction{ + SiacoinOutputs: make([]types.SiacoinOutput, 20), + } + for i := range txn.SiacoinOutputs { + txn.SiacoinOutputs[i] = types.SiacoinOutput{ + Value: initialReward.Div64(20), + Address: w.Address(), + } + } + + // try funding the transaction, expect it to fail since the outputs are immature + _, err = w.FundTransaction(&txn, initialReward, false) + if !errors.Is(err, wallet.ErrNotEnoughFunds) { + t.Fatal("expected ErrNotEnoughFunds, got", err) + } + + // mine until the payout matures + tip := cm.TipState() + target := tip.MaturityHeight() + mineAndSync(t, cm, ws, types.VoidAddress, target-tip.Index.Height) + + // check that one payout has matured + assertBalance(t, w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency) + + // check that the wallet has a single event + count, err := w.EventCount() + if err != nil { + t.Fatal(err) + } else if count != 1 { + t.Fatalf("expected 1 transaction, got %v", count) + } + + // check that the payout transaction was created + events, err := w.Events(0, 100) + if err != nil { + t.Fatal(err) + } else if len(events) != 1 { + t.Fatalf("expected 1 transaction, got %v", len(events)) + } else if events[0].Type != wallet.EventTypeMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Type) + } + + // fund and sign the transaction + toSign, err := w.FundTransaction(&txn, initialReward, false) + if err != nil { + t.Fatal(err) + } + w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true}) + + // check that wallet now has no spendable balance + assertBalance(t, w, types.ZeroCurrency, initialReward, types.ZeroCurrency, types.ZeroCurrency) + + // check the wallet has no unconfirmed transactions + poolTxns, err := w.UnconfirmedTransactions() + if err != nil { + t.Fatal(err) + } else if len(poolTxns) != 0 { + t.Fatalf("expected 0 unconfirmed transaction, got %v", len(poolTxns)) + } + + // 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.Fatalf("expected 1 unconfirmed transaction, got %v", len(poolTxns)) + } else if poolTxns[0].ID != types.Hash256(txn.ID()) { + t.Fatalf("expected transaction %v, got %v", txn.ID(), poolTxns[0].ID) + } else if poolTxns[0].Type != wallet.EventTypeV1Transaction { + t.Fatalf("expected wallet source, got %v", poolTxns[0].Type) + } else if !poolTxns[0].Inflow.Equals(initialReward) { + t.Fatalf("expected %v inflow, got %v", initialReward, poolTxns[0].Inflow) + } else if !poolTxns[0].Outflow.Equals(initialReward) { + t.Fatalf("expected %v outflow, got %v", types.ZeroCurrency, poolTxns[0].Outflow) + } + + // 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. + assertBalance(t, w, types.ZeroCurrency, initialReward, types.ZeroCurrency, initialReward) + // mine a block to confirm the transaction + mineAndSync(t, cm, ws, types.VoidAddress, 1) + + // check that the balance was confirmed and the other values reset + assertBalance(t, w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency) + + // check that the wallet has two events + count, err = w.EventCount() + if err != nil { + t.Fatal(err) + } else if count != 2 { + t.Fatalf("expected 2 transactions, got %v", count) + } + + // check that the paginated transactions are in the proper order + events, err = w.Events(0, 100) + if err != nil { + t.Fatal(err) + } 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 n := len((events[0].Data.(wallet.EventV1Transaction)).SiacoinOutputs); n != 20 { + t.Fatalf("expected 20 outputs, got %v", n) + } + + // mine until the v2 require height + mineAndSync(t, cm, ws, types.VoidAddress, network.HardforkV2.RequireHeight-cm.Tip().Height) + + v2Txn := types.V2Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + {Address: types.VoidAddress, Value: types.Siacoins(100)}, + }, + } + + // fund and sign the transaction + state, toSignV2, err := w.FundV2Transaction(&v2Txn, types.Siacoins(100), false) + if err != nil { + t.Fatal(err) + } + w.SignV2Inputs(state, &v2Txn, toSignV2) + + // add the transaction to the pool + if _, err := cm.AddV2PoolTransactions(state.Index, []types.V2Transaction{v2Txn}); 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.Fatalf("expected 1 unconfirmed transaction, got %v", len(poolTxns)) + } else if poolTxns[0].ID != types.Hash256(v2Txn.ID()) { + t.Fatalf("expected transaction %v, got %v", v2Txn.ID(), poolTxns[0].ID) + } else if poolTxns[0].Type != wallet.EventTypeV2Transaction { + t.Fatalf("expected v2 transaction type, got %v", poolTxns[0].Type) + } + + // confirm the transaction + mineAndSync(t, cm, ws, types.VoidAddress, 1) + + // check that the wallet has three events + count, err = w.EventCount() + if err != nil { + t.Fatal(err) + } else if count != 3 { + t.Fatalf("expected 3 events, got %v", count) + } + + // check that the new transaction is the first event + events, err = w.Events(0, 100) + if err != nil { + t.Fatal(err) + } else if len(events) != 3 { + t.Fatalf("expected 3 events, got %v", len(events)) + } else if events[0].ID != types.Hash256(v2Txn.ID()) { + t.Fatalf("expected transaction %v, got %v", v2Txn.ID(), events[0].ID) + } else if events[0].Type != wallet.EventTypeV2Transaction { + t.Fatalf("expected v2 transaction type, got %v", events[0].Type) + } +} + +func TestReorgV2(t *testing.T) { + // create wallet store + pk := types.GeneratePrivateKey() + ws := testutil.NewEphemeralWalletStore(pk) + + // create chain store + network, genesis := testutil.Network() + network.HardforkV2.AllowHeight = 10 + network.HardforkV2.RequireHeight = 20 + cs, tipState, err := chain.NewDBStore(chain.NewMemDB(), network, genesis) + if err != nil { + t.Fatal(err) + } + + // create chain manager and subscribe the wallet + cm := chain.NewManager(cs, tipState) + + // create wallet + l := zaptest.NewLogger(t) + w, err := wallet.NewSingleAddressWallet(pk, cm, ws, wallet.WithLogger(l.Named("wallet"))) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + // check balance + assertBalance(t, w, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency) + + // mine a block to fund the wallet + mineAndSync(t, cm, ws, w.Address(), 1) + maturityHeight := cm.TipState().MaturityHeight() + // mine until the require height + mineAndSync(t, cm, ws, types.VoidAddress, network.HardforkV2.RequireHeight-cm.Tip().Height) + + // 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].Type != wallet.EventTypeMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Type) + } 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 + initialReward := cm.TipState().BlockReward() + assertBalance(t, w, types.ZeroCurrency, types.ZeroCurrency, initialReward, types.ZeroCurrency) + + // create a transaction that splits the wallet's balance into 20 outputs + txn := types.V2Transaction{ + SiacoinOutputs: make([]types.SiacoinOutput, 20), + } + for i := range txn.SiacoinOutputs { + txn.SiacoinOutputs[i] = types.SiacoinOutput{ + Value: initialReward.Div64(20), + Address: w.Address(), + } + } + + // try funding the transaction, expect it to fail since the outputs are immature + _, _, err = w.FundV2Transaction(&txn, initialReward, false) + if !errors.Is(err, wallet.ErrNotEnoughFunds) { + t.Fatal("expected ErrNotEnoughFunds, got", err) + } + + // mine until the payout matures + tip := cm.TipState() + target := tip.MaturityHeight() + mineAndSync(t, cm, ws, types.VoidAddress, target-tip.Index.Height) + + // check that one payout has matured + assertBalance(t, w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency) + + // check that the wallet still has a single event + count, err := w.EventCount() + if err != nil { + t.Fatal(err) + } else if count != 1 { + t.Fatalf("expected 1 transaction, got %v", count) + } + + // check that the payout transaction was created + events, err := w.Events(0, 100) + if err != nil { + t.Fatal(err) + } else if len(events) != 1 { + t.Fatalf("expected 1 transaction, got %v", len(events)) + } else if events[0].Type != wallet.EventTypeMinerPayout { + t.Fatalf("expected miner payout, got %v", events[0].Type) + } + + // fund and sign the transaction + state, toSign, err := w.FundV2Transaction(&txn, initialReward, false) + if err != nil { + t.Fatal(err) + } + w.SignV2Inputs(state, &txn, toSign) + + // check that wallet now has no spendable balance + assertBalance(t, w, types.ZeroCurrency, initialReward, types.ZeroCurrency, types.ZeroCurrency) + + // check the wallet has no unconfirmed transactions + poolTxns, err := w.UnconfirmedTransactions() + if err != nil { + t.Fatal(err) + } else if len(poolTxns) != 0 { + t.Fatalf("expected 0 unconfirmed transaction, got %v", len(poolTxns)) + } + + // add the transaction to the pool + if _, err := cm.AddV2PoolTransactions(state.Index, []types.V2Transaction{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.Fatalf("expected 1 unconfirmed transaction, got %v", len(poolTxns)) + } else if poolTxns[0].ID != types.Hash256(txn.ID()) { + t.Fatalf("expected transaction %v, got %v", txn.ID(), poolTxns[0].ID) + } else if poolTxns[0].Type != wallet.EventTypeV2Transaction { + t.Fatalf("expected v2 transaction type, got %v", poolTxns[0].Type) + } else if !poolTxns[0].Inflow.Equals(initialReward) { + t.Fatalf("expected %v inflow, got %v", initialReward, poolTxns[0].Inflow) + } else if !poolTxns[0].Outflow.Equals(initialReward) { + t.Fatalf("expected %v outflow, got %v", types.ZeroCurrency, poolTxns[0].Outflow) + } + + // 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. + assertBalance(t, w, types.ZeroCurrency, initialReward, types.ZeroCurrency, initialReward) + // mine a block to confirm the transaction + mineAndSync(t, cm, ws, types.VoidAddress, 1) + + // save a marker to this state to rollback to later + rollbackState := cm.TipState() + + // check that the balance was confirmed and the other values reset + assertBalance(t, w, initialReward, initialReward, types.ZeroCurrency, types.ZeroCurrency) + + // check that the wallet has two events + count, err = w.EventCount() + if err != nil { + t.Fatal(err) + } else if count != 2 { + t.Fatalf("expected 2 transactions, got %v", count) + } + + // check that the paginated transactions are in the proper order + events, err = w.Events(0, 100) + if err != nil { + t.Fatal(err) + } 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 n := len((events[0].Data.(wallet.EventV2Transaction)).SiacoinOutputs); n != 20 { + t.Fatalf("expected 20 outputs, got %v", n) + } + + txn2 := types.V2Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + {Address: types.VoidAddress, Value: initialReward}, + }, + } + state, toSign, err = w.FundV2Transaction(&txn2, initialReward, false) + if err != nil { + t.Fatal(err) + } + w.SignV2Inputs(state, &txn2, toSign) + + // release the inputs to construct a double spend + w.ReleaseInputs(nil, []types.V2Transaction{txn2}) + + txn1 := types.V2Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + {Address: types.VoidAddress, Value: initialReward.Div64(2)}, + }, + } + state, toSign, err = w.FundV2Transaction(&txn1, initialReward.Div64(2), false) + if err != nil { + t.Fatal(err) + } + w.SignV2Inputs(state, &txn1, toSign) + + // add the first transaction to the pool + if _, err := cm.AddV2PoolTransactions(state.Index, []types.V2Transaction{txn1}); err != nil { + t.Fatal(err) + } + mineAndSync(t, cm, ws, types.VoidAddress, 1) + + // check that the wallet now has 3 transactions: the initial payout + // transaction, the split transaction, and a void transaction + count, err = w.EventCount() + if err != nil { + t.Fatal(err) + } else if count != 3 { + t.Fatalf("expected 3 transactions, got %v", count) + } + + events, err = w.Events(0, 1) // limit of 1 so the original two transactions are not included + if err != nil { + t.Fatal(err) + } else if len(events) != 1 { + t.Fatalf("expected 1 transactions, got %v", len(events)) + } else if events[0].ID != types.Hash256(txn1.ID()) { + t.Fatalf("expected transaction %v, got %v", txn1.ID(), events[0].ID) + } + + // check that the wallet balance has half the initial reward + assertBalance(t, w, initialReward.Div64(2), initialReward.Div64(2), types.ZeroCurrency, types.ZeroCurrency) + + // spend the second transaction to invalidate the confirmed transaction + state = rollbackState + b := types.Block{ + ParentID: state.Index.ID, + Timestamp: types.CurrentTimestamp(), + MinerPayouts: []types.SiacoinOutput{{Address: types.VoidAddress, Value: state.BlockReward()}}, + V2: &types.V2BlockData{ + Height: state.Index.Height + 1, + Transactions: []types.V2Transaction{txn2}, + }, + } + b.V2.Commitment = state.Commitment(state.TransactionsCommitment(b.Transactions, b.V2Transactions()), b.MinerPayouts[0].Address) + if !coreutils.FindBlockNonce(state, &b, time.Second) { + t.Fatal("failed to find nonce") + } + ancestorTimestamp, _ := cs.AncestorTimestamp(b.ParentID) + state, _ = consensus.ApplyBlock(state, b, cs.SupplementTipBlock(b), ancestorTimestamp) + reorgBlocks := []types.Block{b} + for i := 0; i < 5; i++ { + b := types.Block{ + ParentID: state.Index.ID, + Timestamp: types.CurrentTimestamp(), + MinerPayouts: []types.SiacoinOutput{{Address: types.VoidAddress, Value: state.BlockReward()}}, + V2: &types.V2BlockData{ + Height: state.Index.Height + 1, + }, + } + b.V2.Commitment = state.Commitment(state.TransactionsCommitment(b.Transactions, b.V2Transactions()), b.MinerPayouts[0].Address) + if !coreutils.FindBlockNonce(state, &b, time.Second) { + t.Fatal("failed to find nonce") + } + ancestorTimestamp, _ := cs.AncestorTimestamp(b.ParentID) + state, _ = consensus.ApplyBlock(state, b, cs.SupplementTipBlock(b), ancestorTimestamp) + reorgBlocks = append(reorgBlocks, b) + } + + if err := cm.AddBlocks(reorgBlocks); err != nil { + t.Fatal(err) + } else if err := syncDB(cm, ws); err != nil { + t.Fatal(err) + } else if cm.Tip() != state.Index { + t.Fatalf("expected tip %v, got %v", state.Index, cm.Tip()) + } + + // check that the original transaction is now invalid + if _, err := cm.AddV2PoolTransactions(state.Index, []types.V2Transaction{txn1}); err == nil { + t.Fatalf("expected double-spend error, got nil") + } + + // all balances should now be zero + assertBalance(t, w, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency, types.ZeroCurrency) + + // check that the wallet is back to two events + count, err = w.EventCount() + if err != nil { + t.Fatal(err) + } else if count != 3 { + t.Fatalf("expected 3 transactions, got %v", count) + } + + events, err = w.Events(0, 100) + if err != nil { + t.Fatal(err) + } else if len(events) != 3 { + t.Fatalf("expected 3 transactions, got %v", len(events)) + } else if events[0].ID != types.Hash256(txn2.ID()) { // new transaction first + t.Fatalf("expected transaction %v, got %v", txn2.ID(), events[0].ID) + } else if events[1].ID != types.Hash256(txn.ID()) { // split transaction second + t.Fatalf("expected transaction %v, got %v", txn.ID(), events[1].ID) + } else if events[2].Type != wallet.EventTypeMinerPayout { // payout transaction last + t.Fatalf("expected miner payout, got %v", events[0].Type) } }