diff --git a/api/api.go b/api/api.go index 0014bd4..fb8f010 100644 --- a/api/api.go +++ b/api/api.go @@ -51,7 +51,6 @@ type BalanceResponse wallet.Balance type WalletReserveRequest struct { SiacoinOutputs []types.SiacoinOutputID `json:"siacoinOutputs"` SiafundOutputs []types.SiafundOutputID `json:"siafundOutputs"` - Duration time.Duration `json:"duration"` } // A WalletUpdateRequest is a request to update a wallet diff --git a/api/client.go b/api/client.go index 776617b..8ff757a 100644 --- a/api/client.go +++ b/api/client.go @@ -300,7 +300,6 @@ func (c *WalletClient) Reserve(sc []types.SiacoinOutputID, sf []types.SiafundOut err = c.c.POST(fmt.Sprintf("/wallets/%v/reserve", c.id), WalletReserveRequest{ SiacoinOutputs: sc, SiafundOutputs: sf, - Duration: duration, }, nil) return } diff --git a/api/server.go b/api/server.go index 4da2206..e247921 100644 --- a/api/server.go +++ b/api/server.go @@ -6,14 +6,12 @@ import ( "fmt" "net/http" "net/http/pprof" - "reflect" "runtime" "sync" "time" "go.sia.tech/jape" "go.uber.org/zap" - "lukechampine.com/frand" "go.sia.tech/core/consensus" "go.sia.tech/core/gateway" @@ -101,8 +99,10 @@ type ( Addresses(id wallet.ID) ([]wallet.Address, error) WalletEvents(id wallet.ID, offset, limit int) ([]wallet.Event, error) WalletUnconfirmedEvents(id wallet.ID) ([]wallet.Event, error) - UnspentSiacoinOutputs(id wallet.ID, offset, limit int) ([]types.SiacoinElement, error) - UnspentSiafundOutputs(id wallet.ID, offset, limit int) ([]types.SiafundElement, error) + SelectSiacoinElements(walletID wallet.ID, amount types.Currency, useUnconfirmed bool) ([]types.SiacoinElement, types.ChainIndex, types.Currency, error) + SelectSiafundElements(walletID wallet.ID, amount uint64) ([]types.SiafundElement, types.ChainIndex, uint64, error) + UnspentSiacoinOutputs(id wallet.ID, offset, limit int) ([]types.SiacoinElement, types.ChainIndex, error) + UnspentSiafundOutputs(id wallet.ID, offset, limit int) ([]types.SiafundElement, types.ChainIndex, error) WalletBalance(id wallet.ID) (wallet.Balance, error) AddressBalance(address types.Address) (wallet.Balance, error) @@ -116,7 +116,8 @@ type ( SiacoinElement(types.SiacoinOutputID) (types.SiacoinElement, error) SiafundElement(types.SiafundOutputID) (types.SiafundElement, error) - Reserve(ids []types.Hash256, duration time.Duration) error + Reserve([]types.Hash256) error + Release([]types.Hash256) } ) @@ -131,10 +132,6 @@ type server struct { s Syncer wm WalletManager - // for walletsReserveHandler - mu sync.Mutex - used map[types.Hash256]bool - scanMu sync.Mutex // for resubscribe scanInProgress bool scanInfo RescanResponse @@ -533,7 +530,7 @@ func (s *server) walletsOutputsSiacoinHandler(jc jape.Context) { return } - scos, err := s.wm.UnspentSiacoinOutputs(id, offset, limit) + scos, _, err := s.wm.UnspentSiacoinOutputs(id, offset, limit) if jc.Check("couldn't load siacoin outputs", err) != nil { return } @@ -552,7 +549,7 @@ func (s *server) walletsOutputsSiafundHandler(jc jape.Context) { return } - sfos, err := s.wm.UnspentSiafundOutputs(id, offset, limit) + sfos, _, err := s.wm.UnspentSiafundOutputs(id, offset, limit) if jc.Check("couldn't load siacoin outputs", err) != nil { return } @@ -574,95 +571,62 @@ func (s *server) walletsReserveHandler(jc jape.Context) { ids = append(ids, types.Hash256(id)) } - if jc.Check("couldn't reserve outputs", s.wm.Reserve(ids, wrr.Duration)) != nil { + if jc.Check("couldn't reserve outputs", s.wm.Reserve(ids)) != nil { return } jc.EmptyResonse() } func (s *server) walletsReleaseHandler(jc jape.Context) { - var name string var wrr WalletReleaseRequest - if jc.DecodeParam("name", &name) != nil || jc.Decode(&wrr) != nil { + if jc.Decode(&wrr) != nil { return } - s.mu.Lock() - defer s.mu.Unlock() + + ids := make([]types.Hash256, 0, len(wrr.SiacoinOutputs)+len(wrr.SiafundOutputs)) for _, id := range wrr.SiacoinOutputs { - delete(s.used, types.Hash256(id)) + ids = append(ids, types.Hash256(id)) } for _, id := range wrr.SiafundOutputs { - delete(s.used, types.Hash256(id)) + ids = append(ids, types.Hash256(id)) } + s.wm.Release(ids) jc.EmptyResonse() } func (s *server) walletsFundHandler(jc jape.Context) { - fundTxn := func(txn *types.Transaction, amount types.Currency, utxos []types.SiacoinElement, changeAddr types.Address, pool []types.Transaction) ([]types.Hash256, error) { - s.mu.Lock() - defer s.mu.Unlock() - if amount.IsZero() { - return nil, nil - } - inPool := make(map[types.Hash256]bool) - for _, ptxn := range pool { - for _, in := range ptxn.SiacoinInputs { - inPool[types.Hash256(in.ParentID)] = true - } - } - frand.Shuffle(len(utxos), reflect.Swapper(utxos)) - var outputSum types.Currency - var fundingElements []types.SiacoinElement - for _, sce := range utxos { - if s.used[types.Hash256(sce.ID)] || inPool[types.Hash256(sce.ID)] { - continue - } - fundingElements = append(fundingElements, sce) - outputSum = outputSum.Add(sce.SiacoinOutput.Value) - if outputSum.Cmp(amount) >= 0 { - break - } - } - if outputSum.Cmp(amount) < 0 { - return nil, errors.New("insufficient balance") - } else if outputSum.Cmp(amount) > 0 { - if changeAddr == types.VoidAddress { - return nil, errors.New("change address must be specified") - } - txn.SiacoinOutputs = append(txn.SiacoinOutputs, types.SiacoinOutput{ - Value: outputSum.Sub(amount), - Address: changeAddr, - }) - } - - toSign := make([]types.Hash256, len(fundingElements)) - for i, sce := range fundingElements { - txn.SiacoinInputs = append(txn.SiacoinInputs, types.SiacoinInput{ - ParentID: types.SiacoinOutputID(sce.ID), - // UnlockConditions left empty for client to fill in - }) - toSign[i] = types.Hash256(sce.ID) - s.used[types.Hash256(sce.ID)] = true - } - - return toSign, nil - } - var id wallet.ID var wfr WalletFundRequest if jc.DecodeParam("id", &id) != nil || jc.Decode(&wfr) != nil { return } - utxos, err := s.wm.UnspentSiacoinOutputs(id, 0, 1000) + utxos, _, change, err := s.wm.SelectSiacoinElements(id, wfr.Amount, false) if jc.Check("couldn't get utxos to fund transaction", err) != nil { return } txn := wfr.Transaction - toSign, err := fundTxn(&txn, wfr.Amount, utxos, wfr.ChangeAddress, s.cm.PoolTransactions()) - if jc.Check("couldn't fund transaction", err) != nil { - return + if !change.IsZero() { + if wfr.ChangeAddress == types.VoidAddress { + jc.Error(errors.New("change address must be specified"), http.StatusBadRequest) + return + } + + txn.SiacoinOutputs = append(txn.SiacoinOutputs, types.SiacoinOutput{ + Value: change, + Address: wfr.ChangeAddress, + }) } + + toSign := make([]types.Hash256, 0, len(utxos)) + for _, sce := range utxos { + txn.SiacoinInputs = append(txn.SiacoinInputs, types.SiacoinInput{ + ParentID: sce.ID, + // UnlockConditions left empty for client to fill in + }) + toSign = append(toSign, types.Hash256(sce.ID)) + } + jc.Encode(WalletFundResponse{ Transaction: txn, ToSign: toSign, @@ -671,71 +635,37 @@ func (s *server) walletsFundHandler(jc jape.Context) { } func (s *server) walletsFundSFHandler(jc jape.Context) { - fundTxn := func(txn *types.Transaction, amount uint64, utxos []types.SiafundElement, changeAddr, claimAddr types.Address, pool []types.Transaction) ([]types.Hash256, error) { - s.mu.Lock() - defer s.mu.Unlock() - if amount == 0 { - return nil, nil - } - inPool := make(map[types.Hash256]bool) - for _, ptxn := range pool { - for _, in := range ptxn.SiafundInputs { - inPool[types.Hash256(in.ParentID)] = true - } - } - frand.Shuffle(len(utxos), reflect.Swapper(utxos)) - var outputSum uint64 - var fundingElements []types.SiafundElement - for _, sfe := range utxos { - if s.used[types.Hash256(sfe.ID)] || inPool[types.Hash256(sfe.ID)] { - continue - } - fundingElements = append(fundingElements, sfe) - outputSum += sfe.SiafundOutput.Value - if outputSum >= amount { - break - } - } - if outputSum < amount { - return nil, errors.New("insufficient balance") - } else if outputSum > amount { - if changeAddr == types.VoidAddress { - return nil, errors.New("change address must be specified") - } - txn.SiafundOutputs = append(txn.SiafundOutputs, types.SiafundOutput{ - Value: outputSum - amount, - Address: changeAddr, - }) - } - - toSign := make([]types.Hash256, len(fundingElements)) - for i, sfe := range fundingElements { - txn.SiafundInputs = append(txn.SiafundInputs, types.SiafundInput{ - ParentID: types.SiafundOutputID(sfe.ID), - ClaimAddress: claimAddr, - // UnlockConditions left empty for client to fill in - }) - toSign[i] = types.Hash256(sfe.ID) - s.used[types.Hash256(sfe.ID)] = true - } - - return toSign, nil - } - var id wallet.ID var wfr WalletFundSFRequest if jc.DecodeParam("id", &id) != nil || jc.Decode(&wfr) != nil { return } - utxos, err := s.wm.UnspentSiafundOutputs(id, 0, 1000) + utxos, _, change, err := s.wm.SelectSiafundElements(id, wfr.Amount) if jc.Check("couldn't get utxos to fund transaction", err) != nil { return } txn := wfr.Transaction - toSign, err := fundTxn(&txn, wfr.Amount, utxos, wfr.ChangeAddress, wfr.ClaimAddress, s.cm.PoolTransactions()) - if jc.Check("couldn't fund transaction", err) != nil { - return + if change > 0 { + if wfr.ChangeAddress == types.VoidAddress { + jc.Error(errors.New("change address must be specified"), http.StatusBadRequest) + return + } + + txn.SiafundOutputs = append(txn.SiafundOutputs, types.SiafundOutput{ + Value: change, + Address: wfr.ChangeAddress, + }) + } + + toSign := make([]types.Hash256, 0, len(utxos)) + for _, sce := range utxos { + txn.SiafundInputs = append(txn.SiafundInputs, types.SiafundInput{ + ParentID: sce.ID, + ClaimAddress: wfr.ChangeAddress, + // UnlockConditions left empty for client to fill in + }) + toSign = append(toSign, types.Hash256(sce.ID)) } jc.Encode(WalletFundResponse{ Transaction: txn, @@ -924,10 +854,9 @@ func NewServer(cm ChainManager, s Syncer, wm WalletManager, opts ...ServerOption publicEndpoints: false, startTime: time.Now(), - cm: cm, - s: s, - wm: wm, - used: make(map[types.Hash256]bool), + cm: cm, + s: s, + wm: wm, } for _, opt := range opts { opt(&srv) diff --git a/knope.toml b/knope.toml index 2cbe180..1034ad0 100644 --- a/knope.toml +++ b/knope.toml @@ -18,6 +18,7 @@ command = "git switch -c release" [[workflows.steps]] type = "PrepareRelease" +ignore_conventional_commits = true [[workflows.steps]] type = "Command" diff --git a/persist/sqlite/consensus_test.go b/persist/sqlite/consensus_test.go index 107bace..8480192 100644 --- a/persist/sqlite/consensus_test.go +++ b/persist/sqlite/consensus_test.go @@ -137,7 +137,7 @@ func TestPruneSiacoins(t *testing.T) { assertUTXOs(0, 1) // spend the utxo - utxos, err := db.WalletSiacoinOutputs(w.ID, cm.Tip(), 0, 100) + utxos, _, err := db.WalletSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatalf("failed to get wallet siacoin outputs: %v", err) } @@ -262,7 +262,7 @@ func TestPruneSiafunds(t *testing.T) { assertUTXOs(0, 1) // spend the utxo - utxos, err := db.WalletSiafundOutputs(w.ID, 0, 100) + utxos, _, err := db.WalletSiafundOutputs(w.ID, 0, 100) if err != nil { t.Fatalf("failed to get wallet siacoin outputs: %v", err) } diff --git a/persist/sqlite/wallet.go b/persist/sqlite/wallet.go index 45f572f..6176e96 100644 --- a/persist/sqlite/wallet.go +++ b/persist/sqlite/wallet.go @@ -176,6 +176,24 @@ func (s *Store) RemoveWalletAddress(id wallet.ID, address types.Address) error { }) } +// WalletAddress returns an address registered to the wallet. +func (s *Store) WalletAddress(id wallet.ID, address types.Address) (addr wallet.Address, err error) { + err = s.transaction(func(tx *txn) error { + if err := walletExists(tx, id); err != nil { + return err + } + + const query = `SELECT sa.sia_address, wa.description, wa.spend_policy, wa.extra_data +FROM wallet_addresses wa +INNER JOIN sia_addresses sa ON (sa.id = wa.address_id) +WHERE wa.wallet_id=$1 AND sa.sia_address=$2` + + addr, err = scanWalletAddress(tx.QueryRow(query, id, encode(address))) + return err + }) + return +} + // WalletAddresses returns a slice of addresses registered to the wallet. func (s *Store) WalletAddresses(id wallet.ID) (addresses []wallet.Address, err error) { err = s.transaction(func(tx *txn) error { @@ -195,27 +213,11 @@ WHERE wa.wallet_id=$1` defer rows.Close() for rows.Next() { - var address wallet.Address - var decodedPolicy any - if err := rows.Scan(decode(&address.Address), &address.Description, &decodedPolicy, (*[]byte)(&address.Metadata)); err != nil { + addr, err := scanWalletAddress(rows) + if err != nil { return fmt.Errorf("failed to scan address: %w", err) } - - if decodedPolicy != nil { - switch v := decodedPolicy.(type) { - case []byte: - dec := types.NewBufDecoder(v) - address.SpendPolicy = new(types.SpendPolicy) - address.SpendPolicy.DecodeFrom(dec) - if err := dec.Err(); err != nil { - return fmt.Errorf("failed to decode spend policy: %w", err) - } - default: - return fmt.Errorf("unexpected spend policy type: %T", decodedPolicy) - } - } - - addresses = append(addresses, address) + addresses = append(addresses, addr) } return rows.Err() }) @@ -223,19 +225,24 @@ WHERE wa.wallet_id=$1` } // WalletSiacoinOutputs returns the unspent siacoin outputs for a wallet. -func (s *Store) WalletSiacoinOutputs(id wallet.ID, index types.ChainIndex, offset, limit int) (siacoins []types.SiacoinElement, err error) { +func (s *Store) WalletSiacoinOutputs(id wallet.ID, offset, limit int) (siacoins []types.SiacoinElement, basis types.ChainIndex, err error) { err = s.transaction(func(tx *txn) error { if err := walletExists(tx, id); err != nil { return err } + basis, err = getScanBasis(tx) + if err != nil { + return fmt.Errorf("failed to get basis: %w", err) + } + const query = `SELECT se.id, se.siacoin_value, se.merkle_proof, se.leaf_index, se.maturity_height, sa.sia_address FROM siacoin_elements se INNER JOIN sia_addresses sa ON (se.address_id = sa.id) WHERE se.spent_index_id IS NULL AND se.maturity_height <= $1 AND se.address_id IN (SELECT address_id FROM wallet_addresses WHERE wallet_id=$2) LIMIT $3 OFFSET $4` - rows, err := tx.Query(query, index.Height, id, limit, offset) + rows, err := tx.Query(query, basis.Height, id, limit, offset) if err != nil { return err } @@ -274,13 +281,18 @@ func (s *Store) WalletSiacoinOutputs(id wallet.ID, index types.ChainIndex, offse } // WalletSiafundOutputs returns the unspent siafund outputs for a wallet. -func (s *Store) WalletSiafundOutputs(id wallet.ID, offset, limit int) (siafunds []types.SiafundElement, err error) { +func (s *Store) WalletSiafundOutputs(id wallet.ID, offset, limit int) (siafunds []types.SiafundElement, basis types.ChainIndex, err error) { err = s.transaction(func(tx *txn) error { if err := walletExists(tx, id); err != nil { return err } - const query = `SELECT se.id, se.leaf_index, se.merkle_proof, se.siafund_value, se.claim_start, sa.sia_address + basis, err = getScanBasis(tx) + if err != nil { + return fmt.Errorf("failed to get basis: %w", err) + } + + const query = `SELECT se.id, se.leaf_index, se.merkle_proof, se.siafund_value, se.claim_start, sa.sia_address FROM siafund_elements se INNER JOIN sia_addresses sa ON (se.address_id = sa.id) WHERE se.spent_index_id IS NULL AND se.address_id IN (SELECT address_id FROM wallet_addresses WHERE wallet_id=$1) @@ -417,7 +429,7 @@ func (s *Store) WalletUnconfirmedEvents(id wallet.ID, index types.ChainIndex, ti return se, nil } - siafundElementStmt, err := tx.Prepare(`SELECT se.id, se.leaf_index, se.merkle_proof, se.siafund_value, se.claim_start, sa.sia_address + siafundElementStmt, err := tx.Prepare(`SELECT se.id, se.leaf_index, se.merkle_proof, se.siafund_value, se.claim_start, sa.sia_address FROM siafund_elements se INNER JOIN sia_addresses sa ON (se.address_id = sa.id) WHERE se.id=$1`) @@ -603,14 +615,45 @@ func scanSiafundElement(s scanner) (se types.SiafundElement, err error) { } func insertAddress(tx *txn, addr types.Address) (id int64, err error) { - const query = `INSERT INTO sia_addresses (sia_address, siacoin_balance, immature_siacoin_balance, siafund_balance) -VALUES ($1, $2, $3, 0) ON CONFLICT (sia_address) DO UPDATE SET sia_address=EXCLUDED.sia_address + const query = `INSERT INTO sia_addresses (sia_address, siacoin_balance, immature_siacoin_balance, siafund_balance) +VALUES ($1, $2, $3, 0) ON CONFLICT (sia_address) DO UPDATE SET sia_address=EXCLUDED.sia_address RETURNING id` err = tx.QueryRow(query, encode(addr), encode(types.ZeroCurrency), encode(types.ZeroCurrency)).Scan(&id) return } +func scanWalletAddress(s scanner) (wallet.Address, error) { + var address wallet.Address + var decodedPolicy any + if err := s.Scan(decode(&address.Address), &address.Description, &decodedPolicy, (*[]byte)(&address.Metadata)); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return wallet.Address{}, wallet.ErrNotFound + } + return wallet.Address{}, fmt.Errorf("failed to scan address: %w", err) + } + + if decodedPolicy != nil { + switch v := decodedPolicy.(type) { + case []byte: + dec := types.NewBufDecoder(v) + address.SpendPolicy = new(types.SpendPolicy) + address.SpendPolicy.DecodeFrom(dec) + if err := dec.Err(); err != nil { + return wallet.Address{}, fmt.Errorf("failed to decode spend policy: %w", err) + } + default: + return wallet.Address{}, fmt.Errorf("unexpected spend policy type: %T", decodedPolicy) + } + } + return address, nil +} + +func getScanBasis(tx *txn) (index types.ChainIndex, err error) { + err = tx.QueryRow(`SELECT last_indexed_id, last_indexed_height FROM global_settings`).Scan(decode(&index.ID), &index.Height) + return +} + func fillElementProofs(tx *txn, indices []uint64) (proofs [][]types.Hash256, _ error) { if len(indices) == 0 { return nil, nil @@ -676,7 +719,7 @@ WITH last_chain_index AS ( SELECT last_indexed_height+1 AS height FROM global_settings LIMIT 1 ), event_ids AS ( - SELECT + SELECT ev.id FROM events ev INNER JOIN event_addresses ea ON ev.id = ea.event_id @@ -686,18 +729,18 @@ event_ids AS ( ORDER BY ev.maturity_height DESC, ev.id DESC LIMIT $2 OFFSET $3 ) -SELECT - ev.id, - ev.event_id, - ev.maturity_height, - ev.date_created, - ci.height, - ci.block_id, - CASE +SELECT + ev.id, + ev.event_id, + ev.maturity_height, + ev.date_created, + ci.height, + ci.block_id, + CASE WHEN last_chain_index.height < ci.height THEN 0 ELSE last_chain_index.height - ci.height END AS confirmations, - ev.event_type, + ev.event_type, ev.event_data FROM events ev INNER JOIN event_ids ei ON ev.id = ei.id diff --git a/wallet/manager.go b/wallet/manager.go index 3c5b7ac..753d661 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "strings" "sync" "time" @@ -36,6 +37,15 @@ const ( const defaultSyncBatchSize = 1 +var ( + // ErrInsufficientFunds is returned when there are not enough funds to + // fund a transaction. + ErrInsufficientFunds = errors.New("insufficient funds") + // ErrAlreadyReserved is returned when trying to reserve an output that is + // already reserved. + ErrAlreadyReserved = errors.New("output already reserved") +) + type ( // An IndexMode determines the chain state that the wallet manager stores. IndexMode uint8 @@ -63,8 +73,9 @@ type ( UpdateWallet(Wallet) (Wallet, error) DeleteWallet(walletID ID) error WalletBalance(walletID ID) (Balance, error) - WalletSiacoinOutputs(walletID ID, index types.ChainIndex, offset, limit int) ([]types.SiacoinElement, error) - WalletSiafundOutputs(walletID ID, offset, limit int) ([]types.SiafundElement, error) + WalletAddress(ID, types.Address) (Address, error) + WalletSiacoinOutputs(walletID ID, offset, limit int) ([]types.SiacoinElement, types.ChainIndex, error) + WalletSiafundOutputs(walletID ID, offset, limit int) ([]types.SiafundElement, types.ChainIndex, error) WalletAddresses(walletID ID) ([]Address, error) Wallets() ([]Wallet, error) @@ -90,6 +101,7 @@ type ( Manager struct { indexMode IndexMode syncBatchSize int + lockDuration time.Duration chain ChainManager store Store @@ -97,7 +109,7 @@ type ( tg *threadgroup.ThreadGroup mu sync.Mutex // protects the fields below - used map[types.Hash256]bool + used map[types.Hash256]time.Time } ) @@ -135,6 +147,27 @@ func (i IndexMode) MarshalText() ([]byte, error) { return []byte(i.String()), nil } +// lockUTXOs locks the given UTXOs for the duration of the lock duration. +// The lock duration is used to prevent double spending when building transactions. +// It is expected that the caller holds the manager's lock. +func (m *Manager) lockUTXOs(ids ...types.Hash256) { + ts := time.Now().Add(m.lockDuration) + for _, id := range ids { + m.used[id] = ts + } +} + +// utxosLocked returns an error if any of the given UTXOs are locked. +// It is expected that the caller holds the manager's lock. +func (m *Manager) utxosLocked(ids ...types.Hash256) error { + for _, id := range ids { + if m.used[id].After(time.Now()) { + return fmt.Errorf("failed to lock output %q: %w", id, ErrAlreadyReserved) + } + } + return nil +} + // Tip returns the last scanned chain index of the manager. func (m *Manager) Tip() (types.ChainIndex, error) { return m.store.LastCommittedIndex() @@ -182,13 +215,13 @@ func (m *Manager) WalletEvents(walletID ID, offset, limit int) ([]Event, error) // UnspentSiacoinOutputs returns a paginated list of matured siacoin outputs // relevant to the wallet -func (m *Manager) UnspentSiacoinOutputs(walletID ID, offset, limit int) ([]types.SiacoinElement, error) { - return m.store.WalletSiacoinOutputs(walletID, m.chain.Tip(), offset, limit) +func (m *Manager) UnspentSiacoinOutputs(walletID ID, offset, limit int) ([]types.SiacoinElement, types.ChainIndex, error) { + return m.store.WalletSiacoinOutputs(walletID, offset, limit) } // UnspentSiafundOutputs returns a paginated list of siafund outputs relevant to // the wallet -func (m *Manager) UnspentSiafundOutputs(walletID ID, offset, limit int) ([]types.SiafundElement, error) { +func (m *Manager) UnspentSiafundOutputs(walletID ID, offset, limit int) ([]types.SiafundElement, types.ChainIndex, error) { return m.store.WalletSiafundOutputs(walletID, offset, limit) } @@ -237,32 +270,256 @@ func (m *Manager) UnconfirmedEvents() ([]Event, error) { } // Reserve reserves the given ids for the given duration. -func (m *Manager) Reserve(ids []types.Hash256, duration time.Duration) error { +func (m *Manager) Reserve(ids []types.Hash256) error { m.mu.Lock() defer m.mu.Unlock() // check if any of the ids are already reserved + if err := m.utxosLocked(ids...); err != nil { + return err + } + m.lockUTXOs(ids...) + return nil +} + +// Release releases the given ids. +func (m *Manager) Release(ids []types.Hash256) { + m.mu.Lock() + defer m.mu.Unlock() + for _, id := range ids { - if m.used[id] { - return fmt.Errorf("output %q already reserved", id) + delete(m.used, id) + } +} + +// SelectSiacoinElements selects siacoin elements from the wallet that sum to +// at least the given amount. Returns the elements, the element basis, and the +// change amount. +func (m *Manager) SelectSiacoinElements(walletID ID, amount types.Currency, useUnconfirmed bool) ([]types.SiacoinElement, types.ChainIndex, types.Currency, error) { + // sanity check that the wallet exists + if _, err := m.WalletBalance(walletID); err != nil { + return nil, types.ChainIndex{}, types.ZeroCurrency, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + knownAddresses := make(map[types.Address]bool) + relevantAddr := func(addr types.Address) (bool, error) { + if exists, ok := knownAddresses[addr]; ok { + return exists, nil + } + _, err := m.store.WalletAddress(walletID, addr) + if errors.Is(err, ErrNotFound) { + knownAddresses[addr] = false + return false, nil + } else if err != nil { + return false, err } + knownAddresses[addr] = true + return true, nil } - // reserve the ids - for _, id := range ids { - m.used[id] = true + ephemeral := make(map[types.SiacoinOutputID]types.SiacoinElement) + inPool := make(map[types.SiacoinOutputID]bool) + for _, txn := range m.chain.PoolTransactions() { + for _, sci := range txn.SiacoinInputs { + inPool[sci.ParentID] = true + delete(ephemeral, sci.ParentID) + } + for i, sco := range txn.SiacoinOutputs { + exists, err := relevantAddr(sco.Address) + if err != nil { + return nil, types.ChainIndex{}, types.ZeroCurrency, fmt.Errorf("failed to check if address %q is relevant: %w", sco.Address, err) + } else if exists { + scoid := txn.SiacoinOutputID(i) + ephemeral[scoid] = types.SiacoinElement{ + ID: scoid, + StateElement: types.StateElement{LeafIndex: types.UnassignedLeafIndex}, + SiacoinOutput: sco, + } + } + } + } + for _, txn := range m.chain.V2PoolTransactions() { + for _, sci := range txn.SiacoinInputs { + inPool[sci.Parent.ID] = true + delete(ephemeral, sci.Parent.ID) + } + for i, sco := range txn.SiacoinOutputs { + exists, err := relevantAddr(sco.Address) + if err != nil { + return nil, types.ChainIndex{}, types.ZeroCurrency, fmt.Errorf("failed to check if address %q is relevant: %w", sco.Address, err) + } else if exists { + sce := txn.EphemeralSiacoinOutput(i) + ephemeral[sce.ID] = sce + } + } + } + + var inputSum types.Currency + var selected []types.SiacoinElement + var utxoIDs []types.Hash256 + var basis types.ChainIndex + const utxoBatchSize = 100 +top: + for i := 0; ; i += utxoBatchSize { + var utxos []types.SiacoinElement + var err error + // extra large wallets may need to paginate through utxos + // to find enough to cover the amount + utxos, basis, err = m.store.WalletSiacoinOutputs(walletID, i, utxoBatchSize) + if err != nil { + return nil, types.ChainIndex{}, types.ZeroCurrency, fmt.Errorf("failed to get siacoin elements: %w", err) + } else if len(utxos) == 0 { + break top + } + + for _, sce := range utxos { + if inPool[sce.ID] || m.utxosLocked(types.Hash256(sce.ID)) != nil { + continue + } + + selected = append(selected, sce) + utxoIDs = append(utxoIDs, types.Hash256(sce.ID)) + inputSum = inputSum.Add(sce.SiacoinOutput.Value) + if inputSum.Cmp(amount) >= 0 { + break top + } + } } - // sleep for the duration and then unreserve the ids - time.AfterFunc(duration, func() { - m.mu.Lock() - defer m.mu.Unlock() + if inputSum.Cmp(amount) < 0 { + if !useUnconfirmed { + return nil, types.ChainIndex{}, types.ZeroCurrency, ErrInsufficientFunds + } + + for _, sce := range ephemeral { + if inPool[sce.ID] || m.utxosLocked(types.Hash256(sce.ID)) != nil { + continue + } - for _, id := range ids { - delete(m.used, id) + selected = append(selected, sce) + inputSum = inputSum.Add(sce.SiacoinOutput.Value) + if inputSum.Cmp(amount) >= 0 { + break + } } - }) - return nil + } + + if inputSum.Cmp(amount) < 0 { + return nil, types.ChainIndex{}, types.ZeroCurrency, ErrInsufficientFunds + } + m.lockUTXOs(utxoIDs...) + return selected, basis, inputSum.Sub(amount), nil +} + +// SelectSiafundElements selects siafund elements from the wallet that sum to +// at least the given amount. Returns the elements, the element basis, and the +// change amount. +func (m *Manager) SelectSiafundElements(walletID ID, amount uint64) ([]types.SiafundElement, types.ChainIndex, uint64, error) { + // sanity check that the wallet exists + if _, err := m.WalletBalance(walletID); err != nil { + return nil, types.ChainIndex{}, 0, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if amount == 0 { + return nil, m.chain.Tip(), 0, nil + } + + knownAddresses := make(map[types.Address]bool) + relevantAddr := func(addr types.Address) (bool, error) { + if exists, ok := knownAddresses[addr]; ok { + return exists, nil + } + _, err := m.store.WalletAddress(walletID, addr) + if errors.Is(err, ErrNotFound) { + knownAddresses[addr] = false + return false, nil + } else if err != nil { + return false, err + } + knownAddresses[addr] = true + return true, nil + } + + ephemeral := make(map[types.SiafundOutputID]types.SiafundElement) + inPool := make(map[types.SiafundOutputID]bool) + for _, txn := range m.chain.PoolTransactions() { + for _, sfi := range txn.SiafundInputs { + inPool[sfi.ParentID] = true + delete(ephemeral, sfi.ParentID) + } + for i, sfo := range txn.SiafundOutputs { + exists, err := relevantAddr(sfo.Address) + if err != nil { + return nil, types.ChainIndex{}, 0, fmt.Errorf("failed to check if address %q is relevant: %w", sfo.Address, err) + } else if exists { + sfoid := txn.SiafundOutputID(i) + ephemeral[sfoid] = types.SiafundElement{ + ID: sfoid, + StateElement: types.StateElement{LeafIndex: types.UnassignedLeafIndex}, + SiafundOutput: sfo, + } + } + } + } + for _, txn := range m.chain.V2PoolTransactions() { + for _, sfi := range txn.SiafundInputs { + inPool[sfi.Parent.ID] = true + delete(ephemeral, sfi.Parent.ID) + } + for i, sfo := range txn.SiafundOutputs { + exists, err := relevantAddr(sfo.Address) + if err != nil { + return nil, types.ChainIndex{}, 0, fmt.Errorf("failed to check if address %q is relevant: %w", sfo.Address, err) + } else if exists { + sfe := txn.EphemeralSiafundOutput(i) + ephemeral[sfe.ID] = sfe + } + } + } + + var inputSum uint64 + var selected []types.SiafundElement + var utxoIDs []types.Hash256 + var basis types.ChainIndex + const utxoBatchSize = 100 +top: + for i := 0; ; i += utxoBatchSize { + var utxos []types.SiafundElement + var err error + + utxos, basis, err = m.store.WalletSiafundOutputs(walletID, i, utxoBatchSize) + if err != nil { + return nil, types.ChainIndex{}, 0, fmt.Errorf("failed to get siafund elements: %w", err) + } else if len(utxos) == 0 { + break top + } + + for _, sfe := range utxos { + if inPool[sfe.ID] || m.utxosLocked(types.Hash256(sfe.ID)) != nil { + continue + } + + selected = append(selected, sfe) + utxoIDs = append(utxoIDs, types.Hash256(sfe.ID)) + inputSum += sfe.SiafundOutput.Value + if inputSum >= amount { + break top + } + } + } + + if inputSum < amount { + return nil, types.ChainIndex{}, 0, ErrInsufficientFunds + } + + m.lockUTXOs(utxoIDs...) + return selected, basis, inputSum - amount, nil } // Scan rescans the chain starting from the given index. The scan will complete @@ -333,11 +590,14 @@ func NewManager(cm ChainManager, store Store, opts ...Option) (*Manager, error) m := &Manager{ indexMode: IndexModePersonal, syncBatchSize: defaultSyncBatchSize, + lockDuration: time.Hour, chain: cm, store: store, log: zap.NewNop(), tg: threadgroup.New(), + + used: make(map[types.Hash256]time.Time), } for _, opt := range opts { @@ -362,6 +622,32 @@ func NewManager(cm ChainManager, store Store, opts ...Option) (*Manager, error) } }) + go func() { + ctx, cancel, err := m.tg.AddWithContext(context.Background()) + if err != nil { + log.Panic("failed to add to threadgroup", zap.Error(err)) + } + defer cancel() + + t := time.NewTicker(m.lockDuration / 2) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + m.mu.Lock() + for id, ts := range m.used { + if ts.Before(time.Now()) { + delete(m.used, id) + } + } + m.mu.Unlock() + } + } + }() + go func() { defer unsubscribe() diff --git a/wallet/options.go b/wallet/options.go index 79075e1..34e19af 100644 --- a/wallet/options.go +++ b/wallet/options.go @@ -1,6 +1,10 @@ package wallet -import "go.uber.org/zap" +import ( + "time" + + "go.uber.org/zap" +) // An Option configures a wallet Manager. type Option func(*Manager) @@ -27,3 +31,11 @@ func WithSyncBatchSize(size int) Option { m.syncBatchSize = size } } + +// WithLockDuration sets the duration that a UTXO is locked after +// being selected as an input to a transaction. The default is 1 hour. +func WithLockDuration(d time.Duration) Option { + return func(m *Manager) { + m.lockDuration = d + } +} diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index d672093..dbfb663 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "math/bits" "path/filepath" @@ -21,6 +22,7 @@ import ( "go.sia.tech/walletd/wallet" "go.uber.org/zap" "go.uber.org/zap/zaptest" + "lukechampine.com/frand" ) func waitForBlock(tb testing.TB, cm *chain.Manager, ws wallet.Store) { @@ -35,6 +37,15 @@ func waitForBlock(tb testing.TB, cm *chain.Manager, ws wallet.Store) { tb.Fatal("timed out waiting for block") } +func mineAndSync(tb testing.TB, cm *chain.Manager, ws wallet.Store, addr types.Address, n int) { + tb.Helper() + + for i := 0; i < n; i++ { + testutil.MineBlocks(tb, cm, addr, 1) + waitForBlock(tb, cm, ws) + } +} + func testV1Network(siafundAddr types.Address) (*consensus.Network, types.Block) { // use a modified version of Zen n, genesisBlock := chain.TestnetZen() @@ -98,6 +109,386 @@ func mineV2Block(state consensus.State, txns []types.V2Transaction, minerAddr ty return b } +func TestReserve(t *testing.T) { + log := zaptest.NewLogger(t) + dir := t.TempDir() + db, err := sqlite.OpenDatabase(filepath.Join(dir, "walletd.sqlite3"), log.Named("sqlite3")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + bdb, err := coreutils.OpenBoltChainDB(filepath.Join(dir, "consensus.db")) + if err != nil { + t.Fatal(err) + } + defer bdb.Close() + + network, genesisBlock := testutil.V2Network() + store, genesisState, err := chain.NewDBStore(bdb, network, genesisBlock) + if err != nil { + t.Fatal(err) + } + cm := chain.NewManager(store, genesisState) + + wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet")), wallet.WithLockDuration(2*time.Second)) + if err != nil { + t.Fatal(err) + } + defer wm.Close() + + w, err := wm.AddWallet(wallet.Wallet{Name: "test"}) + if err != nil { + t.Fatal(err) + } + + sk := types.GeneratePrivateKey() + sp := types.SpendPolicy{Type: types.PolicyTypePublicKey(sk.PublicKey())} + addr := sp.Address() + + err = wm.AddAddress(w.ID, wallet.Address{ + Address: addr, + SpendPolicy: &sp, + }) + if err != nil { + t.Fatal(err) + } + + scoID := types.Hash256(frand.Entropy256()) + if err := wm.Reserve([]types.Hash256{scoID}); err != nil { + t.Fatal(err) + } + + // output should be locked + if err := wm.Reserve([]types.Hash256{scoID}); !errors.Is(err, wallet.ErrAlreadyReserved) { + t.Fatalf("expected output locked error, got %v", err) + } + + time.Sleep(3 * time.Second) + + // output should be unlocked + if err := wm.Reserve([]types.Hash256{scoID}); err != nil { + t.Fatal(err) + } + + // output should be locked + if err := wm.Reserve([]types.Hash256{scoID}); !errors.Is(err, wallet.ErrAlreadyReserved) { + t.Fatalf("expected output locked error, got %v", err) + } + + wm.Release([]types.Hash256{scoID}) + // output should be unlocked + if err := wm.Reserve([]types.Hash256{scoID}); err != nil { + t.Fatal(err) + } +} + +func TestSelectSiacoins(t *testing.T) { + log := zaptest.NewLogger(t) + dir := t.TempDir() + db, err := sqlite.OpenDatabase(filepath.Join(dir, "walletd.sqlite3"), log.Named("sqlite3")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + bdb, err := coreutils.OpenBoltChainDB(filepath.Join(dir, "consensus.db")) + if err != nil { + t.Fatal(err) + } + defer bdb.Close() + + network, genesisBlock := testutil.Network() + network.InitialCoinbase = types.Siacoins(100) + network.MinimumCoinbase = types.Siacoins(100) + + store, genesisState, err := chain.NewDBStore(bdb, network, genesisBlock) + if err != nil { + t.Fatal(err) + } + cm := chain.NewManager(store, genesisState) + + wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet"))) + if err != nil { + t.Fatal(err) + } + defer wm.Close() + + w, err := wm.AddWallet(wallet.Wallet{Name: "test"}) + if err != nil { + t.Fatal(err) + } + + sk := types.GeneratePrivateKey() + uc := types.UnlockConditions{ + PublicKeys: []types.UnlockKey{sk.PublicKey().UnlockKey()}, + SignaturesRequired: 1, + } + addr := uc.UnlockHash() + + err = wm.AddAddress(w.ID, wallet.Address{ + Address: addr, + SpendPolicy: &types.SpendPolicy{ + Type: types.PolicyTypeUnlockConditions(uc), + }, + }) + if err != nil { + t.Fatal(err) + } + + mineAndSync := func(t *testing.T, addr types.Address, n int) { + t.Helper() + + for i := 0; i < n; i++ { + testutil.MineBlocks(t, cm, addr, 1) + waitForBlock(t, cm, db) + } + } + // mine enough utxos to ensure the pagination works + mineAndSync(t, addr, 200) + // mine until all the wallet's outputs are mature + mineAndSync(t, types.VoidAddress, int(cm.TipState().Network.MaturityDelay)) + + // check that the wallet has 200 matured outputs + utxos, _, err := wm.UnspentSiacoinOutputs(w.ID, 0, 1000) + if err != nil { + t.Fatal(err) + } else if len(utxos) != 200 { + t.Fatalf("expected 200 outputs, got %v", len(utxos)) + } + + balance, err := wm.WalletBalance(w.ID) + if err != nil { + t.Fatal(err) + } + + // fund a transaction with more than the wallet balance + _, _, _, err = wm.SelectSiacoinElements(w.ID, balance.Siacoins.Add(types.Siacoins(1)), false) + if !errors.Is(err, wallet.ErrInsufficientFunds) { + t.Fatal("expected insufficient funds error") + } + + // fund multiple overlapping transactions to ensure no double spends + var selected []types.Hash256 + seen := make(map[types.SiacoinOutputID]bool) + for i := 0; i < len(utxos); i++ { + utxos, _, change, err := wm.SelectSiacoinElements(w.ID, types.Siacoins(1), false) + if err != nil { + t.Fatal(err) + } else if len(utxos) != 1 { // one UTXO should always be enough to cover + t.Fatalf("expected 1 output, got %v", len(utxos)) + } else if seen[utxos[0].ID] { + t.Fatalf("double spend %v", utxos[0].ID) + } else if !change.Equals(types.Siacoins(99)) { + t.Fatalf("expected 99 SC change, got %v", change) + } + seen[utxos[0].ID] = true + selected = append(selected, types.Hash256(utxos[0].ID)) + } + + // all available outputs should be locked + _, _, _, err = wm.SelectSiacoinElements(w.ID, types.Siacoins(1), false) + if !errors.Is(err, wallet.ErrInsufficientFunds) { + t.Fatal("expected insufficient funds error") + } + // release the selected outputs + wm.Release(selected) + + // fund and broadcast a transaction + utxos, basis, change, err := wm.SelectSiacoinElements(w.ID, types.Siacoins(101), false) // uses two outputs + if err != nil { + t.Fatal(err) + } else if len(utxos) != 2 { + t.Fatalf("expected 2 outputs, got %v", len(utxos)) + } else if !change.Equals(types.Siacoins(99)) { + t.Fatalf("expected 99 SC change, got %v", change) + } else if basis != cm.Tip() { + t.Fatalf("expected tip, got %v", basis) + } + txn := types.Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + {Address: types.VoidAddress, Value: types.Siacoins(101)}, + {Address: addr, Value: change}, + }, + } + for _, utxo := range utxos { + txn.SiacoinInputs = append(txn.SiacoinInputs, types.SiacoinInput{ + ParentID: types.SiacoinOutputID(utxo.ID), + UnlockConditions: uc, + }) + txn.Signatures = append(txn.Signatures, types.TransactionSignature{ + ParentID: types.Hash256(utxo.ID), + CoveredFields: types.CoveredFields{WholeTransaction: true}, + }) + } + for i := range txn.Signatures { + sigHash := cm.TipState().WholeSigHash(txn, txn.Signatures[i].ParentID, 0, 0, nil) + sig := sk.SignHash(sigHash) + txn.Signatures[i].Signature = sig[:] + } + + known, err := cm.AddPoolTransactions([]types.Transaction{txn}) + if err != nil { + t.Fatal(err) + } else if known { + t.Fatal("transaction was already known") + } + + mineAndSync(t, types.VoidAddress, 1) + + events, err := wm.WalletEvents(w.ID, 0, 1) + if 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.EventTypeV1Transaction { + t.Fatalf("expected transaction event, got %v", events[0].Type) + } else if events[0].ID != types.Hash256(txn.ID()) { + t.Fatalf("expected %v, got %v", txn.ID(), events[0].ID) + } else if !events[0].SiacoinOutflow().Sub(events[0].SiacoinInflow()).Equals(types.Siacoins(101)) { + t.Fatalf("expected transaction value 101 SC, got %v", events[0].SiacoinOutflow().Sub(events[0].SiacoinInflow())) + } +} + +func TestSelectSiafunds(t *testing.T) { + log := zaptest.NewLogger(t) + dir := t.TempDir() + db, err := sqlite.OpenDatabase(filepath.Join(dir, "walletd.sqlite3"), log.Named("sqlite3")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + bdb, err := coreutils.OpenBoltChainDB(filepath.Join(dir, "consensus.db")) + if err != nil { + t.Fatal(err) + } + defer bdb.Close() + + sk := types.GeneratePrivateKey() + uc := types.UnlockConditions{ + PublicKeys: []types.UnlockKey{sk.PublicKey().UnlockKey()}, + SignaturesRequired: 1, + } + addr := uc.UnlockHash() + + network, genesisBlock := testutil.Network() + genesisBlock.Transactions[0].SiafundOutputs[0].Address = addr + network.InitialCoinbase = types.Siacoins(100) + network.MinimumCoinbase = types.Siacoins(100) + + store, genesisState, err := chain.NewDBStore(bdb, network, genesisBlock) + if err != nil { + t.Fatal(err) + } + cm := chain.NewManager(store, genesisState) + + wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet"))) + if err != nil { + t.Fatal(err) + } + defer wm.Close() + + w, err := wm.AddWallet(wallet.Wallet{Name: "test"}) + if err != nil { + t.Fatal(err) + } + + err = wm.AddAddress(w.ID, wallet.Address{ + Address: addr, + SpendPolicy: &types.SpendPolicy{ + Type: types.PolicyTypeUnlockConditions(uc), + }, + }) + if err != nil { + t.Fatal(err) + } + + mineAndSync := func(t *testing.T, addr types.Address, n int) { + t.Helper() + + for i := 0; i < n; i++ { + testutil.MineBlocks(t, cm, addr, 1) + waitForBlock(t, cm, db) + } + } + mineAndSync(t, types.VoidAddress, 1) + + // check that the wallet has a siafund utxo + utxos, _, err := wm.UnspentSiafundOutputs(w.ID, 0, 1000) + if err != nil { + t.Fatal(err) + } else if len(utxos) != 1 { + t.Fatalf("expected 1 outputs, got %v", len(utxos)) + } + + balance, err := wm.WalletBalance(w.ID) + if err != nil { + t.Fatal(err) + } + + // fund a transaction with more than the wallet balance + _, _, _, err = wm.SelectSiafundElements(w.ID, balance.Siafunds+1) + if !errors.Is(err, wallet.ErrInsufficientFunds) { + t.Fatal("expected insufficient funds error") + } + + // fund and broadcast a transaction + utxos, basis, change, err := wm.SelectSiafundElements(w.ID, balance.Siafunds/2) // uses two outputs + if err != nil { + t.Fatal(err) + } else if len(utxos) != 1 { + t.Fatalf("expected 1 utxo, got %v", len(utxos)) + } else if change != balance.Siafunds/2 { + t.Fatalf("expected %v SF change, got %v", balance.Siafunds/2, change) + } else if basis != cm.Tip() { + t.Fatalf("expected tip, got %v", basis) + } + txn := types.Transaction{ + SiafundOutputs: []types.SiafundOutput{ + {Address: types.VoidAddress, Value: balance.Siafunds / 2}, + {Address: addr, Value: change}, + }, + } + for _, utxo := range utxos { + txn.SiafundInputs = append(txn.SiafundInputs, types.SiafundInput{ + ParentID: types.SiafundOutputID(utxo.ID), + UnlockConditions: uc, + }) + txn.Signatures = append(txn.Signatures, types.TransactionSignature{ + ParentID: types.Hash256(utxo.ID), + CoveredFields: types.CoveredFields{WholeTransaction: true}, + }) + } + for i := range txn.Signatures { + sigHash := cm.TipState().WholeSigHash(txn, txn.Signatures[i].ParentID, 0, 0, nil) + sig := sk.SignHash(sigHash) + txn.Signatures[i].Signature = sig[:] + } + + known, err := cm.AddPoolTransactions([]types.Transaction{txn}) + if err != nil { + t.Fatal(err) + } else if known { + t.Fatal("transaction was already known") + } + + mineAndSync(t, types.VoidAddress, 1) + + events, err := wm.WalletEvents(w.ID, 0, 1) + if 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.EventTypeV1Transaction { + t.Fatalf("expected transaction event, got %v", events[0].Type) + } else if events[0].ID != types.Hash256(txn.ID()) { + t.Fatalf("expected %v, got %v", txn.ID(), events[0].ID) + } else if events[0].SiafundOutflow()-events[0].SiafundInflow() != balance.Siafunds/2 { + t.Fatalf("expected transaction value %v SF, got %v", balance.Siafunds/2, events[0].SiafundOutflow()-events[0].SiafundInflow()) + } +} + func TestReorg(t *testing.T) { pk := types.GeneratePrivateKey() addr := types.StandardUnlockHash(pk.PublicKey()) @@ -177,7 +568,7 @@ func TestReorg(t *testing.T) { } // check that the utxo has not matured - utxos, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 { @@ -212,7 +603,7 @@ func TestReorg(t *testing.T) { } // check that the utxo was removed - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 { @@ -243,7 +634,7 @@ func TestReorg(t *testing.T) { } // check that the utxo has not matured - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 { @@ -286,7 +677,7 @@ func TestReorg(t *testing.T) { } // check that only the single utxo still exists - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -387,7 +778,7 @@ func TestEphemeralBalance(t *testing.T) { waitForBlock(t, cm, db) // create a transaction that spends the matured payout - utxos, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -1043,7 +1434,7 @@ func TestOrphans(t *testing.T) { } // check that the utxo was created - utxos, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -1144,7 +1535,7 @@ func TestOrphans(t *testing.T) { } // check that the utxo was reverted - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -1683,25 +2074,10 @@ func TestWalletUnconfirmedEvents(t *testing.T) { } // mine a block sending the payout to the wallet - b, ok := coreutils.MineBlock(cm, addr1, time.Minute) - if !ok { - t.Fatal("failed to mine block") - } else if err := cm.AddBlocks([]types.Block{b}); err != nil { - t.Fatal(err) - } - - // mine until the payout matures - maturityHeight := cm.TipState().MaturityHeight() - for i := cm.TipState().Index.Height; i < maturityHeight; i++ { - b, ok := coreutils.MineBlock(cm, types.VoidAddress, time.Minute) - if !ok { - t.Fatal("failed to mine block") - } else if err := cm.AddBlocks([]types.Block{b}); err != nil { - t.Fatal(err) - } - } + mineAndSync(t, cm, db, addr1, 1) + mineAndSync(t, cm, db, types.VoidAddress, int(network.MaturityDelay)) - utxos, err := wm.UnspentSiacoinOutputs(w1.ID, 0, 100) + utxos, _, err := wm.UnspentSiacoinOutputs(w1.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -1830,12 +2206,7 @@ func TestWalletUnconfirmedEvents(t *testing.T) { } // mine the transactions - b, ok = coreutils.MineBlock(cm, types.VoidAddress, time.Minute) - if !ok { - t.Fatal("failed to mine block") - } else if err := cm.AddBlocks([]types.Block{b}); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, db, types.VoidAddress, 1) // check that the unconfirmed events were removed events, err = wm.WalletUnconfirmedEvents(w1.ID) @@ -1891,25 +2262,11 @@ func TestAddressUnconfirmedEvents(t *testing.T) { } // mine a block sending the payout to the wallet - b, ok := coreutils.MineBlock(cm, addr1, time.Minute) - if !ok { - t.Fatal("failed to mine block") - } else if err := cm.AddBlocks([]types.Block{b}); err != nil { - t.Fatal(err) - } - + mineAndSync(t, cm, db, addr1, 1) // mine until the payout matures - maturityHeight := cm.TipState().MaturityHeight() - for i := cm.TipState().Index.Height; i < maturityHeight; i++ { - b, ok := coreutils.MineBlock(cm, types.VoidAddress, time.Minute) - if !ok { - t.Fatal("failed to mine block") - } else if err := cm.AddBlocks([]types.Block{b}); err != nil { - t.Fatal(err) - } - } + mineAndSync(t, cm, db, types.VoidAddress, int(network.MaturityDelay)) - utxos, err := wm.UnspentSiacoinOutputs(w1.ID, 0, 100) + utxos, _, err := wm.UnspentSiacoinOutputs(w1.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -2047,12 +2404,7 @@ func TestAddressUnconfirmedEvents(t *testing.T) { } // mine the transactions - b, ok = coreutils.MineBlock(cm, types.VoidAddress, time.Minute) - if !ok { - t.Fatal("failed to mine block") - } else if err := cm.AddBlocks([]types.Block{b}); err != nil { - t.Fatal(err) - } + mineAndSync(t, cm, db, types.VoidAddress, 1) // check that the unconfirmed events were removed events, err = wm.AddressUnconfirmedEvents(addr1) @@ -2110,11 +2462,7 @@ func TestV2(t *testing.T) { } expectedPayout := cm.TipState().BlockReward() - // mine a block sending the payout to the wallet - if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), nil, addr)}); err != nil { - t.Fatal(err) - } - waitForBlock(t, cm, db) + mineAndSync(t, cm, db, addr, 1) // check that the payout was received balance, err := db.AddressBalance(addr) @@ -2135,16 +2483,10 @@ func TestV2(t *testing.T) { } // mine until the payout matures - maturityHeight := cm.TipState().MaturityHeight() + 1 - for i := cm.TipState().Index.Height; i < maturityHeight; i++ { - if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), nil, types.VoidAddress)}); err != nil { - t.Fatal(err) - } - } - waitForBlock(t, cm, db) + mineAndSync(t, cm, db, types.VoidAddress, int(network.MaturityDelay)) // create a v2 transaction that spends the matured payout - utxos, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, basis, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } @@ -2165,10 +2507,10 @@ func TestV2(t *testing.T) { } txn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(cm.TipState().InputSigHash(txn))} - if err := cm.AddBlocks([]types.Block{mineV2Block(cm.TipState(), []types.V2Transaction{txn}, types.VoidAddress)}); err != nil { + if _, err := cm.AddV2PoolTransactions(basis, []types.V2Transaction{txn}); err != nil { t.Fatal(err) } - waitForBlock(t, cm, db) + mineAndSync(t, cm, db, types.VoidAddress, 1) // check that the change was received balance, err = wm.AddressBalance(addr) @@ -2454,7 +2796,7 @@ func TestReorgV2(t *testing.T) { } // check that the utxo has not matured - utxos, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 { @@ -2489,7 +2831,7 @@ func TestReorgV2(t *testing.T) { } // check that the utxo was removed - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 { @@ -2520,7 +2862,7 @@ func TestReorgV2(t *testing.T) { } // check that the utxo has not matured - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 { @@ -2563,7 +2905,7 @@ func TestReorgV2(t *testing.T) { } // check that only the single utxo still exists - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -2601,7 +2943,7 @@ func TestReorgV2(t *testing.T) { } // check that all UTXOs have been spent - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 { @@ -2689,7 +3031,7 @@ func TestOrphansV2(t *testing.T) { } // check that the utxo was created - utxos, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err := wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -2781,7 +3123,7 @@ func TestOrphansV2(t *testing.T) { } // check that the utxo was reverted - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -2815,7 +3157,7 @@ func TestOrphansV2(t *testing.T) { } // check that all UTXOs have been spent - utxos, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) + utxos, _, err = wm.UnspentSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 {