Merge pull request #700 from wpaulino/persistent-output-locking
wallet: introduce persistent output leases
This commit is contained in:
commit
c52dcaf17c
7 changed files with 847 additions and 17 deletions
6
go.sum
6
go.sum
|
@ -27,6 +27,7 @@ github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY=
|
|||
github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495 h1:6IyqGr3fnd0tM3YxipK27TUskaOVUjU2nG45yzwcQKY=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
|
@ -59,6 +60,8 @@ github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI
|
|||
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk=
|
||||
github.com/lightninglabs/neutrino v0.11.0 h1:lPpYFCtsfJX2W5zI4pWycPmbbBdr7zU+BafYdLoD6k0=
|
||||
github.com/lightninglabs/neutrino v0.11.0/go.mod h1:CuhF0iuzg9Sp2HO6ZgXgayviFTn1QHdSTJlMncK80wg=
|
||||
github.com/lightningnetwork/lnd/clock v1.0.1 h1:QQod8+m3KgqHdvVMV+2DRNNZS1GRFir8mHZYA+Z2hFo=
|
||||
github.com/lightningnetwork/lnd/clock v1.0.1/go.mod h1:KnQudQ6w0IAMZi1SgvecLZQZ43ra2vpDNj7H/aasemg=
|
||||
github.com/lightningnetwork/lnd/queue v1.0.1 h1:jzJKcTy3Nj5lQrooJ3aaw9Lau3I0IwvQR5sqtjdv2R0=
|
||||
github.com/lightningnetwork/lnd/queue v1.0.1/go.mod h1:vaQwexir73flPW43Mrm7JOgJHmcEFBWWSl9HlyASoms=
|
||||
github.com/lightningnetwork/lnd/ticker v1.0.0 h1:S1b60TEGoTtCe2A0yeB+ecoj/kkS4qpwh6l+AkQEZwU=
|
||||
|
@ -71,6 +74,9 @@ github.com/onsi/gomega v1.4.1 h1:PZSj/UFNaVp3KxrzHOcS7oyuWA7LoOY/77yCTEFu21U=
|
|||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44 h1:9lP3x0pW80sDI6t1UMSLA4to18W7R7imwAI/sWS9S8Q=
|
||||
|
|
|
@ -315,9 +315,9 @@ func (w *Wallet) SetChainSynced(synced bool) {
|
|||
// activeData returns the currently-active receiving addresses and all unspent
|
||||
// outputs. This is primarely intended to provide the parameters for a
|
||||
// rescan request.
|
||||
func (w *Wallet) activeData(dbtx walletdb.ReadTx) ([]btcutil.Address, []wtxmgr.Credit, error) {
|
||||
func (w *Wallet) activeData(dbtx walletdb.ReadWriteTx) ([]btcutil.Address, []wtxmgr.Credit, error) {
|
||||
addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey)
|
||||
txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey)
|
||||
txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey)
|
||||
|
||||
var addrs []btcutil.Address
|
||||
err := w.Manager.ForEachRelevantActiveAddress(
|
||||
|
@ -329,6 +329,16 @@ func (w *Wallet) activeData(dbtx walletdb.ReadTx) ([]btcutil.Address, []wtxmgr.C
|
|||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Before requesting the list of spendable UTXOs, we'll delete any
|
||||
// expired output locks.
|
||||
err = w.TxStore.DeleteExpiredLockedOutputs(
|
||||
dbtx.ReadWriteBucket(wtxmgrNamespaceKey),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
unspent, err := w.TxStore.UnspentOutputs(txmgrNs)
|
||||
return addrs, unspent, err
|
||||
}
|
||||
|
@ -508,7 +518,7 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error {
|
|||
addrs []btcutil.Address
|
||||
unspent []wtxmgr.Credit
|
||||
)
|
||||
err = walletdb.View(w.db, func(dbtx walletdb.ReadTx) error {
|
||||
err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error {
|
||||
addrs, unspent, err = w.activeData(dbtx)
|
||||
return err
|
||||
})
|
||||
|
@ -2854,6 +2864,42 @@ func (w *Wallet) LockedOutpoints() []btcjson.TransactionInput {
|
|||
return locked
|
||||
}
|
||||
|
||||
// LeaseOutput locks an output to the given ID, preventing it from being
|
||||
// available for coin selection. The absolute time of the lock's expiration is
|
||||
// returned. The expiration of the lock can be extended by successive
|
||||
// invocations of this call.
|
||||
//
|
||||
// Outputs can be unlocked before their expiration through `UnlockOutput`.
|
||||
// Otherwise, they are unlocked lazily through calls which iterate through all
|
||||
// known outputs, e.g., `CalculateBalance`, `ListUnspent`.
|
||||
//
|
||||
// If the output is not known, ErrUnknownOutput is returned. If the output has
|
||||
// already been locked to a different ID, then ErrOutputAlreadyLocked is
|
||||
// returned.
|
||||
//
|
||||
// NOTE: This differs from LockOutpoint in that outputs are locked for a limited
|
||||
// amount of time and their locks are persisted to disk.
|
||||
func (w *Wallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint) (time.Time, error) {
|
||||
var expiry time.Time
|
||||
err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error {
|
||||
ns := tx.ReadWriteBucket(wtxmgrNamespaceKey)
|
||||
var err error
|
||||
expiry, err = w.TxStore.LockOutput(ns, id, op)
|
||||
return err
|
||||
})
|
||||
return expiry, err
|
||||
}
|
||||
|
||||
// ReleaseOutput unlocks an output, allowing it to be available for coin
|
||||
// selection if it remains unspent. The ID should match the one used to
|
||||
// originally lock the output.
|
||||
func (w *Wallet) ReleaseOutput(id wtxmgr.LockID, op wire.OutPoint) error {
|
||||
return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error {
|
||||
ns := tx.ReadWriteBucket(wtxmgrNamespaceKey)
|
||||
return w.TxStore.UnlockOutput(ns, id, op)
|
||||
})
|
||||
}
|
||||
|
||||
// resendUnminedTxs iterates through all transactions that spend from wallet
|
||||
// credits that are not known to have been mined into a block, and attempts
|
||||
// to send each to the chain server for relay.
|
||||
|
|
132
wtxmgr/db.go
132
wtxmgr/db.go
|
@ -69,6 +69,7 @@ var (
|
|||
bucketUnmined = []byte("m")
|
||||
bucketUnminedCredits = []byte("mc")
|
||||
bucketUnminedInputs = []byte("mi")
|
||||
bucketLockedOutputs = []byte("lo")
|
||||
)
|
||||
|
||||
// Root (namespace) bucket keys
|
||||
|
@ -116,10 +117,10 @@ func putMinedBalance(ns walletdb.ReadWriteBucket, amt btcutil.Amount) error {
|
|||
// The canonical transaction hash serialization is simply the hash.
|
||||
|
||||
func canonicalOutPoint(txHash *chainhash.Hash, index uint32) []byte {
|
||||
k := make([]byte, 36)
|
||||
copy(k, txHash[:])
|
||||
var k [36]byte
|
||||
copy(k[:32], txHash[:])
|
||||
byteOrder.PutUint32(k[32:36], index)
|
||||
return k
|
||||
return k[:]
|
||||
}
|
||||
|
||||
func readCanonicalOutPoint(k []byte, op *wire.OutPoint) error {
|
||||
|
@ -1287,6 +1288,123 @@ func deleteRawUnminedInput(ns walletdb.ReadWriteBucket, outPointKey []byte,
|
|||
return nil
|
||||
}
|
||||
|
||||
// serializeLockedOutput serializes the value of a locked output.
|
||||
func serializeLockedOutput(id LockID, expiry time.Time) []byte {
|
||||
var v [len(id) + 8]byte
|
||||
copy(v[:len(id)], id[:])
|
||||
byteOrder.PutUint64(v[len(id):], uint64(expiry.Unix()))
|
||||
return v[:]
|
||||
}
|
||||
|
||||
// deserializeLockedOutput deserializes the value of a locked output.
|
||||
func deserializeLockedOutput(v []byte) (LockID, time.Time) {
|
||||
var id LockID
|
||||
copy(id[:], v[:len(id)])
|
||||
expiry := time.Unix(int64(byteOrder.Uint64(v[len(id):])), 0)
|
||||
return id, expiry
|
||||
}
|
||||
|
||||
// isLockedOutput determines whether an output is locked. If it is, its assigned
|
||||
// ID is returned, along with its absolute expiration time. If the output lock
|
||||
// exists, but its expiration has been met, then the output is considered
|
||||
// unlocked.
|
||||
func isLockedOutput(ns walletdb.ReadBucket, op wire.OutPoint,
|
||||
timeNow time.Time) (LockID, time.Time, bool) {
|
||||
|
||||
// The bucket may not exist, indicating that no outputs have ever been
|
||||
// locked, so we can just return now.
|
||||
lockedOutputs := ns.NestedReadBucket(bucketLockedOutputs)
|
||||
if lockedOutputs == nil {
|
||||
return LockID{}, time.Time{}, false
|
||||
}
|
||||
|
||||
// Retrieve the output lock, if any, and extract the relevant fields.
|
||||
k := canonicalOutPoint(&op.Hash, op.Index)
|
||||
v := lockedOutputs.Get(k)
|
||||
if v == nil {
|
||||
return LockID{}, time.Time{}, false
|
||||
}
|
||||
lockID, expiry := deserializeLockedOutput(v)
|
||||
|
||||
// If the output lock has already expired, delete it now.
|
||||
if !timeNow.Before(expiry) {
|
||||
return LockID{}, time.Time{}, false
|
||||
}
|
||||
|
||||
return lockID, expiry, true
|
||||
}
|
||||
|
||||
// lockOutput creates a lock for `duration` over an output assigned to the `id`,
|
||||
// preventing it from becoming eligible for coin selection.
|
||||
func lockOutput(ns walletdb.ReadWriteBucket, id LockID, op wire.OutPoint,
|
||||
expiry time.Time) error {
|
||||
|
||||
// Create the corresponding bucket if necessary.
|
||||
lockedOutputs, err := ns.CreateBucketIfNotExists(bucketLockedOutputs)
|
||||
if err != nil {
|
||||
str := "failed to create locked outputs bucket"
|
||||
return storeError(ErrDatabase, str, err)
|
||||
}
|
||||
|
||||
// Store a mapping of outpoint -> (id, expiry).
|
||||
k := canonicalOutPoint(&op.Hash, op.Index)
|
||||
v := serializeLockedOutput(id, expiry)
|
||||
|
||||
if err := lockedOutputs.Put(k, v[:]); err != nil {
|
||||
str := fmt.Sprintf("%s: put failed for %v", bucketLockedOutputs,
|
||||
op)
|
||||
return storeError(ErrDatabase, str, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unlockOutput removes a lock over an output, making it eligible for coin
|
||||
// selection if still unspent.
|
||||
func unlockOutput(ns walletdb.ReadWriteBucket, op wire.OutPoint) error {
|
||||
// The bucket may not exist, indicating that no outputs have ever been
|
||||
// locked, so we can just return now.
|
||||
lockedOutputs := ns.NestedReadWriteBucket(bucketLockedOutputs)
|
||||
if lockedOutputs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the key-value pair representing the output lock.
|
||||
k := canonicalOutPoint(&op.Hash, op.Index)
|
||||
if err := lockedOutputs.Delete(k); err != nil {
|
||||
str := fmt.Sprintf("%s: delete failed for %v",
|
||||
bucketLockedOutputs, op)
|
||||
return storeError(ErrDatabase, str, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// forEachLockedOutput iterates over all existing locked outputs and invokes the
|
||||
// callback `f` for each.
|
||||
func forEachLockedOutput(ns walletdb.ReadBucket,
|
||||
f func(wire.OutPoint, LockID, time.Time)) error {
|
||||
|
||||
// The bucket may not exist, indicating that no outputs have ever been
|
||||
// locked, so we can just return now.
|
||||
lockedOutputs := ns.NestedReadBucket(bucketLockedOutputs)
|
||||
if lockedOutputs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return lockedOutputs.ForEach(func(k, v []byte) error {
|
||||
var op wire.OutPoint
|
||||
if err := readCanonicalOutPoint(k, &op); err != nil {
|
||||
return err
|
||||
}
|
||||
lockID, expiry := deserializeLockedOutput(v)
|
||||
|
||||
f(op, lockID, expiry)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// openStore opens an existing transaction store from the passed namespace.
|
||||
func openStore(ns walletdb.ReadBucket) error {
|
||||
version, err := fetchVersion(ns)
|
||||
|
@ -1382,6 +1500,10 @@ func createBuckets(ns walletdb.ReadWriteBucket) error {
|
|||
str := "failed to create unmined inputs bucket"
|
||||
return storeError(ErrDatabase, str, err)
|
||||
}
|
||||
if _, err := ns.CreateBucket(bucketLockedOutputs); err != nil {
|
||||
str := "failed to create locked outputs bucket"
|
||||
return storeError(ErrDatabase, str, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1421,6 +1543,10 @@ func deleteBuckets(ns walletdb.ReadWriteBucket) error {
|
|||
str := "failed to delete unmined inputs bucket"
|
||||
return storeError(ErrDatabase, str, err)
|
||||
}
|
||||
if err := ns.DeleteNestedBucket(bucketLockedOutputs); err != nil {
|
||||
str := "failed to delete locked outputs bucket"
|
||||
return storeError(ErrDatabase, str, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -7,4 +7,6 @@ require (
|
|||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
||||
github.com/btcsuite/btcwallet/walletdb v1.2.0
|
||||
github.com/lightningnetwork/lnd/clock v1.0.1
|
||||
github.com/stretchr/testify v1.5.1 // indirect
|
||||
)
|
||||
|
|
|
@ -17,6 +17,7 @@ github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46f
|
|||
github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY=
|
||||
github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
|
@ -28,11 +29,18 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
|
|||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/lightningnetwork/lnd/clock v1.0.1 h1:QQod8+m3KgqHdvVMV+2DRNNZS1GRFir8mHZYA+Z2hFo=
|
||||
github.com/lightningnetwork/lnd/clock v1.0.1/go.mod h1:KnQudQ6w0IAMZi1SgvecLZQZ43ra2vpDNj7H/aasemg=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44 h1:9lP3x0pW80sDI6t1UMSLA4to18W7R7imwAI/sWS9S8Q=
|
||||
|
@ -56,3 +64,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep
|
|||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
|
205
wtxmgr/tx.go
205
wtxmgr/tx.go
|
@ -18,10 +18,16 @@ import (
|
|||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btcutil"
|
||||
"github.com/btcsuite/btcwallet/walletdb"
|
||||
"github.com/lightningnetwork/lnd/clock"
|
||||
)
|
||||
|
||||
const (
|
||||
// TxLabelLimit is the length limit we impose on transaction labels.
|
||||
const TxLabelLimit = 500
|
||||
TxLabelLimit = 500
|
||||
|
||||
// DefaultLockDuration is the default duration used to lock outputs.
|
||||
DefaultLockDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEmptyLabel is returned when an attempt to write a label that is
|
||||
|
@ -40,6 +46,18 @@ var (
|
|||
// ErrTxLabelNotFound is returned when no label is found for a
|
||||
// transaction hash.
|
||||
ErrTxLabelNotFound = errors.New("label for transaction not found")
|
||||
|
||||
// ErrUnknownOutput is an error returned when an output not known to the
|
||||
// wallet is attempted to be locked.
|
||||
ErrUnknownOutput = errors.New("unknown output")
|
||||
|
||||
// ErrOutputAlreadyLocked is an error returned when an output has
|
||||
// already been locked to a different ID.
|
||||
ErrOutputAlreadyLocked = errors.New("output already locked")
|
||||
|
||||
// ErrOutputUnlockNotAllowed is an error returned when an output unlock
|
||||
// is attempted with a different ID than the one which locked it.
|
||||
ErrOutputUnlockNotAllowed = errors.New("output unlock not alowed")
|
||||
)
|
||||
|
||||
// Block contains the minimum amount of data to uniquely identify any block on
|
||||
|
@ -155,25 +173,33 @@ type Credit struct {
|
|||
FromCoinBase bool
|
||||
}
|
||||
|
||||
// LockID represents a unique context-specific ID assigned to an output lock.
|
||||
type LockID [32]byte
|
||||
|
||||
// Store implements a transaction store for storing and managing wallet
|
||||
// transactions.
|
||||
type Store struct {
|
||||
chainParams *chaincfg.Params
|
||||
|
||||
// clock is used to determine when outputs locks have expired.
|
||||
clock clock.Clock
|
||||
|
||||
// Event callbacks. These execute in the same goroutine as the wtxmgr
|
||||
// caller.
|
||||
NotifyUnspent func(hash *chainhash.Hash, index uint32)
|
||||
}
|
||||
|
||||
// Open opens the wallet transaction store from a walletdb namespace. If the
|
||||
// store does not exist, ErrNoExist is returned.
|
||||
// store does not exist, ErrNoExist is returned. `lockDuration` represents how
|
||||
// long outputs are locked for.
|
||||
func Open(ns walletdb.ReadBucket, chainParams *chaincfg.Params) (*Store, error) {
|
||||
|
||||
// Open the store.
|
||||
err := openStore(ns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{chainParams, nil} // TODO: set callbacks
|
||||
s := &Store{chainParams, clock.NewDefaultClock(), nil} // TODO: set callbacks
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
@ -401,7 +427,19 @@ func (s *Store) insertMinedTx(ns walletdb.ReadWriteBucket, rec *TxRecord,
|
|||
// from the unconfirmed set. This also handles removing unconfirmed
|
||||
// transaction spend chains if any other unconfirmed transactions spend
|
||||
// outputs of the removed double spend.
|
||||
return s.removeDoubleSpends(ns, rec)
|
||||
if err := s.removeDoubleSpends(ns, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear any locked outputs since we now have a confirmed spend for
|
||||
// them, making them not eligible for coin selection anyway.
|
||||
for _, txIn := range rec.MsgTx.TxIn {
|
||||
if err := unlockOutput(ns, txIn.PreviousOutPoint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddCredit marks a transaction record as containing a transaction output
|
||||
|
@ -740,11 +778,19 @@ func (s *Store) UnspentOutputs(ns walletdb.ReadBucket) ([]Credit, error) {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the output if it's locked.
|
||||
_, _, isLocked := isLockedOutput(ns, op, s.clock.Now())
|
||||
if isLocked {
|
||||
return nil
|
||||
}
|
||||
|
||||
if existsRawUnminedInput(ns, k) != nil {
|
||||
// Output is spent by an unmined transaction.
|
||||
// Skip this k/v pair.
|
||||
return nil
|
||||
}
|
||||
|
||||
err = readUnspentBlock(v, &block)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -786,17 +832,22 @@ func (s *Store) UnspentOutputs(ns walletdb.ReadBucket) ([]Credit, error) {
|
|||
}
|
||||
|
||||
err = ns.NestedReadBucket(bucketUnminedCredits).ForEach(func(k, v []byte) error {
|
||||
if err := readCanonicalOutPoint(k, &op); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the output if it's locked.
|
||||
_, _, isLocked := isLockedOutput(ns, op, s.clock.Now())
|
||||
if isLocked {
|
||||
return nil
|
||||
}
|
||||
|
||||
if existsRawUnminedInput(ns, k) != nil {
|
||||
// Output is spent by an unmined transaction.
|
||||
// Skip to next unmined credit.
|
||||
return nil
|
||||
}
|
||||
|
||||
err := readCanonicalOutPoint(k, &op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(jrick): Reading/parsing the entire transaction record
|
||||
// just for the output amount and script can be avoided.
|
||||
recVal := existsRawUnmined(ns, op.Hash[:])
|
||||
|
@ -858,6 +909,22 @@ func (s *Store) Balance(ns walletdb.ReadBucket, minConf int32, syncHeight int32)
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Subtract the output's amount if it's locked.
|
||||
_, _, isLocked := isLockedOutput(ns, op, s.clock.Now())
|
||||
if isLocked {
|
||||
_, v := existsCredit(ns, &op.Hash, op.Index, &block)
|
||||
amt, err := fetchRawCreditAmount(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bal -= amt
|
||||
|
||||
// To prevent decrementing the balance twice if the
|
||||
// output has an unconfirmed spend, return now.
|
||||
return nil
|
||||
}
|
||||
|
||||
if existsRawUnminedInput(ns, k) != nil {
|
||||
_, v := existsCredit(ns, &op.Hash, op.Index, &block)
|
||||
amt, err := fetchRawCreditAmount(v)
|
||||
|
@ -866,6 +933,7 @@ func (s *Store) Balance(ns walletdb.ReadBucket, minConf int32, syncHeight int32)
|
|||
}
|
||||
bal -= amt
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -902,7 +970,14 @@ func (s *Store) Balance(ns walletdb.ReadBucket, minConf int32, syncHeight int32)
|
|||
for i := uint32(0); i < numOuts; i++ {
|
||||
// Avoid double decrementing the credit amount
|
||||
// if it was already removed for being spent by
|
||||
// an unmined tx.
|
||||
// an unmined tx or being locked.
|
||||
op = wire.OutPoint{Hash: *txHash, Index: i}
|
||||
_, _, isLocked := isLockedOutput(
|
||||
ns, op, s.clock.Now(),
|
||||
)
|
||||
if isLocked {
|
||||
continue
|
||||
}
|
||||
opKey := canonicalOutPoint(txHash, i)
|
||||
if existsRawUnminedInput(ns, opKey) != nil {
|
||||
continue
|
||||
|
@ -935,6 +1010,17 @@ func (s *Store) Balance(ns walletdb.ReadBucket, minConf int32, syncHeight int32)
|
|||
// output that is unspent.
|
||||
if minConf == 0 {
|
||||
err = ns.NestedReadBucket(bucketUnminedCredits).ForEach(func(k, v []byte) error {
|
||||
if err := readCanonicalOutPoint(k, &op); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip adding the balance for this output if it's
|
||||
// locked.
|
||||
_, _, isLocked := isLockedOutput(ns, op, s.clock.Now())
|
||||
if isLocked {
|
||||
return nil
|
||||
}
|
||||
|
||||
if existsRawUnminedInput(ns, k) != nil {
|
||||
// Output is spent by an unmined transaction.
|
||||
// Skip to next unmined credit.
|
||||
|
@ -1041,3 +1127,102 @@ func DeserializeLabel(v []byte) (string, error) {
|
|||
label := string(v[2:])
|
||||
return label, nil
|
||||
}
|
||||
|
||||
// isKnownOutput returns whether the output is known to the transaction store
|
||||
// either as confirmed or unconfirmed.
|
||||
func isKnownOutput(ns walletdb.ReadWriteBucket, op wire.OutPoint) bool {
|
||||
k := canonicalOutPoint(&op.Hash, op.Index)
|
||||
if existsRawUnminedCredit(ns, k) != nil {
|
||||
return true
|
||||
}
|
||||
if existsRawUnspent(ns, k) != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LockOutput locks an output to the given ID, preventing it from being
|
||||
// available for coin selection. The absolute time of the lock's expiration is
|
||||
// returned. The expiration of the lock can be extended by successive
|
||||
// invocations of this call.
|
||||
//
|
||||
// Outputs can be unlocked before their expiration through `UnlockOutput`.
|
||||
// Otherwise, they are unlocked lazily through calls which iterate through all
|
||||
// known outputs, e.g., `Balance`, `UnspentOutputs`.
|
||||
//
|
||||
// If the output is not known, ErrUnknownOutput is returned. If the output has
|
||||
// already been locked to a different ID, then ErrOutputAlreadyLocked is
|
||||
// returned.
|
||||
func (s *Store) LockOutput(ns walletdb.ReadWriteBucket, id LockID,
|
||||
op wire.OutPoint) (time.Time, error) {
|
||||
|
||||
// Make sure the output is known.
|
||||
if !isKnownOutput(ns, op) {
|
||||
return time.Time{}, ErrUnknownOutput
|
||||
}
|
||||
|
||||
// Make sure the output hasn't already been locked to some other ID.
|
||||
lockedID, _, isLocked := isLockedOutput(ns, op, s.clock.Now())
|
||||
if isLocked && lockedID != id {
|
||||
return time.Time{}, ErrOutputAlreadyLocked
|
||||
}
|
||||
|
||||
expiry := s.clock.Now().Add(DefaultLockDuration)
|
||||
if err := lockOutput(ns, id, op, expiry); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
return expiry, nil
|
||||
}
|
||||
|
||||
// UnlockOutput unlocks an output, allowing it to be available for coin
|
||||
// selection if it remains unspent. The ID should match the one used to
|
||||
// originally lock the output.
|
||||
func (s *Store) UnlockOutput(ns walletdb.ReadWriteBucket, id LockID,
|
||||
op wire.OutPoint) error {
|
||||
|
||||
// Make sure the output is known.
|
||||
if !isKnownOutput(ns, op) {
|
||||
return ErrUnknownOutput
|
||||
}
|
||||
|
||||
// If the output has already been unlocked, we can return now.
|
||||
lockedID, _, isLocked := isLockedOutput(ns, op, s.clock.Now())
|
||||
if !isLocked {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Make sure the output was locked to the same ID.
|
||||
if lockedID != id {
|
||||
return ErrOutputUnlockNotAllowed
|
||||
}
|
||||
|
||||
return unlockOutput(ns, op)
|
||||
}
|
||||
|
||||
// DeleteExpiredLockedOutputs iterates through all existing locked outputs and
|
||||
// deletes those which have already expired.
|
||||
func (s *Store) DeleteExpiredLockedOutputs(ns walletdb.ReadWriteBucket) error {
|
||||
// Collect all expired output locks first to remove them later on. This
|
||||
// is necessary as deleting while iterating would invalidate the
|
||||
// iterator.
|
||||
var expiredOutputs []wire.OutPoint
|
||||
err := forEachLockedOutput(
|
||||
ns, func(op wire.OutPoint, _ LockID, expiration time.Time) {
|
||||
if !s.clock.Now().Before(expiration) {
|
||||
expiredOutputs = append(expiredOutputs, op)
|
||||
}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, op := range expiredOutputs {
|
||||
if err := unlockOutput(ns, op); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/btcsuite/btcutil"
|
||||
"github.com/btcsuite/btcwallet/walletdb"
|
||||
_ "github.com/btcsuite/btcwallet/walletdb/bdb"
|
||||
"github.com/lightningnetwork/lnd/clock"
|
||||
)
|
||||
|
||||
// Received transaction output for mainnet outpoint
|
||||
|
@ -2355,3 +2356,457 @@ func TestTxLabel(t *testing.T) {
|
|||
t.Fatalf("expected: %v, got: %v", ErrTxLabelNotFound, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBalance(t *testing.T, s *Store, ns walletdb.ReadWriteBucket,
|
||||
confirmed bool, blockHeight int32, exp btcutil.Amount) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
minConf := int32(0)
|
||||
if confirmed {
|
||||
minConf = 1
|
||||
}
|
||||
balance, err := s.Balance(ns, minConf, blockHeight)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if balance != exp {
|
||||
t.Fatalf("expected balance %v, got %v", exp, balance)
|
||||
}
|
||||
}
|
||||
|
||||
func assertUtxos(t *testing.T, s *Store, ns walletdb.ReadWriteBucket,
|
||||
exp []wire.OutPoint) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
utxos, err := s.UnspentOutputs(ns)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expUtxo := range exp {
|
||||
found := false
|
||||
for _, utxo := range utxos {
|
||||
if expUtxo == utxo.OutPoint {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected utxo %v", expUtxo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertLocked(t *testing.T, ns walletdb.ReadWriteBucket, op wire.OutPoint,
|
||||
timeNow time.Time, exp bool) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
_, _, locked := isLockedOutput(ns, op, timeNow)
|
||||
if locked && locked != exp {
|
||||
t.Fatalf("expected locked output %v", op)
|
||||
}
|
||||
if !locked && locked != exp {
|
||||
t.Fatalf("unexpected locked output %v", op)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOutputLocksExist(t *testing.T, s *Store, ns walletdb.ReadBucket,
|
||||
exp ...wire.OutPoint) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
var found []wire.OutPoint
|
||||
forEachLockedOutput(ns, func(op wire.OutPoint, _ LockID, _ time.Time) {
|
||||
found = append(found, op)
|
||||
})
|
||||
if len(found) != len(exp) {
|
||||
t.Fatalf("expected to find %v locked output(s), found %v",
|
||||
len(exp), len(found))
|
||||
}
|
||||
|
||||
for _, expOp := range exp {
|
||||
exists := false
|
||||
for _, foundOp := range found {
|
||||
if expOp == foundOp {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("expected output lock for %v to exist", expOp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lock(t *testing.T, s *Store, ns walletdb.ReadWriteBucket,
|
||||
id LockID, op wire.OutPoint, exp error) time.Time {
|
||||
|
||||
t.Helper()
|
||||
|
||||
expiry, err := s.LockOutput(ns, id, op)
|
||||
if err != exp {
|
||||
t.Fatalf("expected err %q, got %q", exp, err)
|
||||
}
|
||||
if exp != nil && exp != ErrOutputAlreadyLocked {
|
||||
assertLocked(t, ns, op, s.clock.Now(), false)
|
||||
} else {
|
||||
assertLocked(t, ns, op, s.clock.Now(), true)
|
||||
}
|
||||
return expiry
|
||||
}
|
||||
|
||||
func unlock(t *testing.T, s *Store, ns walletdb.ReadWriteBucket,
|
||||
id LockID, op wire.OutPoint, exp error) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
if err := s.UnlockOutput(ns, id, op); err != exp {
|
||||
t.Fatalf("expected err %q, got %q", exp, err)
|
||||
}
|
||||
if exp != nil {
|
||||
assertLocked(t, ns, op, s.clock.Now(), true)
|
||||
} else {
|
||||
assertLocked(t, ns, op, s.clock.Now(), false)
|
||||
}
|
||||
}
|
||||
|
||||
func insertUnconfirmedCredit(t *testing.T, store *Store, db walletdb.DB,
|
||||
tx *wire.MsgTx, idx uint32) {
|
||||
|
||||
t.Helper()
|
||||
insertConfirmedCredit(t, store, db, tx, idx, nil)
|
||||
}
|
||||
|
||||
func insertConfirmedCredit(t *testing.T, store *Store, db walletdb.DB,
|
||||
tx *wire.MsgTx, idx uint32, block *BlockMeta) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
commitDBTx(t, store, db, func(ns walletdb.ReadWriteBucket) {
|
||||
rec, err := NewTxRecordFromMsgTx(tx, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InsertTx(ns, rec, block); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AddCredit(ns, rec, block, idx, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOutputLocks aims to test all cases revolving output locks, ensuring they
|
||||
// are and aren't eligible for coin selection after certain operations.
|
||||
func TestOutputLocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Define a series of constants we'll use throughout our tests.
|
||||
block := &BlockMeta{
|
||||
Block: Block{
|
||||
Hash: chainhash.Hash{1, 3, 3, 7},
|
||||
Height: 1337,
|
||||
},
|
||||
Time: time.Now(),
|
||||
}
|
||||
|
||||
// Create a coinbase transaction with two outputs, which we'll spend.
|
||||
coinbase := newCoinBase(
|
||||
btcutil.SatoshiPerBitcoin, btcutil.SatoshiPerBitcoin*2,
|
||||
)
|
||||
coinbaseHash := coinbase.TxHash()
|
||||
|
||||
// One of the spends will be unconfirmed.
|
||||
const unconfirmedBalance = btcutil.SatoshiPerBitcoin / 2
|
||||
unconfirmedTx := spendOutput(&coinbaseHash, 0, unconfirmedBalance)
|
||||
unconfirmedOutPoint := wire.OutPoint{
|
||||
Hash: unconfirmedTx.TxHash(),
|
||||
Index: 0,
|
||||
}
|
||||
|
||||
// The other will be confirmed.
|
||||
const confirmedBalance = btcutil.SatoshiPerBitcoin
|
||||
confirmedTx := spendOutput(&coinbaseHash, 1, confirmedBalance)
|
||||
confirmedOutPoint := wire.OutPoint{
|
||||
Hash: confirmedTx.TxHash(),
|
||||
Index: 0,
|
||||
}
|
||||
|
||||
const balance = unconfirmedBalance + confirmedBalance
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
run func(*testing.T, *Store, walletdb.ReadWriteBucket)
|
||||
}{
|
||||
{
|
||||
// Asserts that we cannot lock unknown outputs to the
|
||||
// store.
|
||||
name: "unknown output",
|
||||
run: func(t *testing.T, s *Store, ns walletdb.ReadWriteBucket) {
|
||||
lockID := LockID{1}
|
||||
op := wire.OutPoint{Index: 1}
|
||||
_ = lock(t, s, ns, lockID, op, ErrUnknownOutput)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Asserts that we cannot lock outputs that have already
|
||||
// been locked to someone else.
|
||||
name: "already locked output",
|
||||
run: func(t *testing.T, s *Store, ns walletdb.ReadWriteBucket) {
|
||||
lockID1 := LockID{1}
|
||||
lockID2 := LockID{2}
|
||||
|
||||
_ = lock(
|
||||
t, s, ns, lockID1, unconfirmedOutPoint,
|
||||
nil,
|
||||
)
|
||||
_ = lock(
|
||||
t, s, ns, lockID2, unconfirmedOutPoint,
|
||||
ErrOutputAlreadyLocked,
|
||||
)
|
||||
|
||||
_ = lock(
|
||||
t, s, ns, lockID1, confirmedOutPoint,
|
||||
nil,
|
||||
)
|
||||
_ = lock(
|
||||
t, s, ns, lockID2, confirmedOutPoint,
|
||||
ErrOutputAlreadyLocked,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Asserts that only the ID which locked at output can
|
||||
// manually unlock it.
|
||||
name: "unlock output",
|
||||
run: func(t *testing.T, s *Store, ns walletdb.ReadWriteBucket) {
|
||||
lockID1 := LockID{1}
|
||||
lockID2 := LockID{2}
|
||||
|
||||
_ = lock(t, s, ns, lockID1, confirmedOutPoint, nil)
|
||||
unlock(
|
||||
t, s, ns, lockID2, confirmedOutPoint,
|
||||
ErrOutputUnlockNotAllowed,
|
||||
)
|
||||
unlock(t, s, ns, lockID1, confirmedOutPoint, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Asserts that locking an output that's already locked
|
||||
// with the correct ID results in an extension of the
|
||||
// lock.
|
||||
name: "extend locked output lease",
|
||||
run: func(t *testing.T, s *Store, ns walletdb.ReadWriteBucket) {
|
||||
// Lock the output and set the clock time a
|
||||
// minute before the expiration. It should
|
||||
// remain locked.
|
||||
lockID := LockID{1}
|
||||
expiry := lock(
|
||||
t, s, ns, lockID, confirmedOutPoint, nil,
|
||||
)
|
||||
s.clock.(*clock.TestClock).SetTime(
|
||||
expiry.Add(-time.Minute),
|
||||
)
|
||||
assertLocked(
|
||||
t, ns, confirmedOutPoint, s.clock.Now(),
|
||||
true,
|
||||
)
|
||||
|
||||
// Lock it once again, extending its expiration,
|
||||
// and set the clock time a second before the
|
||||
// expiration. It should remain locked.
|
||||
s.clock.(*clock.TestClock).SetTime(
|
||||
expiry.Add(-time.Minute),
|
||||
)
|
||||
newExpiry := lock(
|
||||
t, s, ns, lockID, confirmedOutPoint, nil,
|
||||
)
|
||||
if !newExpiry.After(expiry) {
|
||||
t.Fatal("expected output lock " +
|
||||
"duration to be renewed")
|
||||
}
|
||||
s.clock.(*clock.TestClock).SetTime(
|
||||
newExpiry.Add(-time.Second),
|
||||
)
|
||||
assertLocked(
|
||||
t, ns, confirmedOutPoint, s.clock.Now(),
|
||||
true,
|
||||
)
|
||||
|
||||
// Set the clock time to the new expiration, it
|
||||
// should now be unlocked.
|
||||
s.clock.(*clock.TestClock).SetTime(newExpiry)
|
||||
assertLocked(
|
||||
t, ns, confirmedOutPoint, s.clock.Now(),
|
||||
false,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Asserts that balances are reflected properly after
|
||||
// locking confirmed and unconfirmed outputs.
|
||||
name: "balance after locked outputs",
|
||||
run: func(t *testing.T, s *Store, ns walletdb.ReadWriteBucket) {
|
||||
// We should see our full balance before locking
|
||||
// any outputs.
|
||||
assertBalance(
|
||||
t, s, ns, false, block.Height, balance,
|
||||
)
|
||||
|
||||
// Lock all of our outputs. Our balance should
|
||||
// be 0.
|
||||
lockID := LockID{1}
|
||||
_ = lock(
|
||||
t, s, ns, lockID, unconfirmedOutPoint, nil,
|
||||
)
|
||||
expiry := lock(
|
||||
t, s, ns, lockID, confirmedOutPoint, nil,
|
||||
)
|
||||
assertBalance(t, s, ns, false, block.Height, 0)
|
||||
|
||||
// Wait for the output locks to expire, causing
|
||||
// our full balance to return .
|
||||
s.clock.(*clock.TestClock).SetTime(expiry)
|
||||
assertBalance(
|
||||
t, s, ns, false, block.Height, balance,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Asserts that the available utxos are reflected
|
||||
// properly after locking confirmed and unconfirmed
|
||||
// outputs.
|
||||
name: "utxos after locked outputs",
|
||||
run: func(t *testing.T, s *Store, ns walletdb.ReadWriteBucket) {
|
||||
// We should see all of our utxos before locking
|
||||
// any.
|
||||
assertUtxos(t, s, ns, []wire.OutPoint{
|
||||
unconfirmedOutPoint,
|
||||
confirmedOutPoint,
|
||||
})
|
||||
|
||||
// Lock the unconfirmed utxo, we should now only
|
||||
// see the confirmed.
|
||||
lockID := LockID{1}
|
||||
_ = lock(t, s, ns, lockID, unconfirmedOutPoint, nil)
|
||||
assertUtxos(t, s, ns, []wire.OutPoint{
|
||||
confirmedOutPoint,
|
||||
})
|
||||
|
||||
// Now lock the confirmed utxo, we should no
|
||||
// longer see any utxos available.
|
||||
expiry := lock(
|
||||
t, s, ns, lockID, confirmedOutPoint, nil,
|
||||
)
|
||||
assertUtxos(t, s, ns, nil)
|
||||
|
||||
// Wait for the output locks to expire for the
|
||||
// utxos to become available once again.
|
||||
s.clock.(*clock.TestClock).SetTime(expiry)
|
||||
assertUtxos(t, s, ns, []wire.OutPoint{
|
||||
unconfirmedOutPoint,
|
||||
confirmedOutPoint,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
// Asserts that output locks are removed for outputs
|
||||
// which have had a confirmed spend, ensuring the
|
||||
// database doesn't store stale data.
|
||||
name: "clear locked outputs after confirmed spend",
|
||||
run: func(t *testing.T, s *Store, ns walletdb.ReadWriteBucket) {
|
||||
// Lock an output.
|
||||
lockID := LockID{1}
|
||||
lock(t, s, ns, lockID, confirmedOutPoint, nil)
|
||||
|
||||
// Create a spend and add it to the store as
|
||||
// confirmed.
|
||||
txHash := confirmedTx.TxHash()
|
||||
spendTx := spendOutput(&txHash, 0, 500)
|
||||
spendRec, err := NewTxRecordFromMsgTx(
|
||||
spendTx, time.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.InsertTx(ns, spendRec, block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The output should no longer be locked.
|
||||
assertLocked(
|
||||
t, ns, confirmedOutPoint, s.clock.Now(),
|
||||
false,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Assert that deleting expired locked outputs works as
|
||||
// intended.
|
||||
name: "delete expired locked outputs",
|
||||
run: func(t *testing.T, s *Store, ns walletdb.ReadWriteBucket) {
|
||||
// Lock an output.
|
||||
lockID := LockID{1}
|
||||
expiry := lock(
|
||||
t, s, ns, lockID, confirmedOutPoint, nil,
|
||||
)
|
||||
|
||||
// We should expect to find it if we iterate
|
||||
// over the locked outputs bucket.
|
||||
assertOutputLocksExist(t, s, ns, confirmedOutPoint)
|
||||
|
||||
// Delete all expired locked outputs. Since the
|
||||
// lock hasn't expired yet, it should still
|
||||
// exist.
|
||||
err := s.DeleteExpiredLockedOutputs(ns)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to delete expired "+
|
||||
"locked outputs: %v", err)
|
||||
}
|
||||
assertOutputLocksExist(t, s, ns, confirmedOutPoint)
|
||||
|
||||
// Let the output lock expired.
|
||||
s.clock.(*clock.TestClock).SetTime(expiry)
|
||||
|
||||
// Delete all expired locked outputs. We should
|
||||
// no longer see any locked outputs.
|
||||
err = s.DeleteExpiredLockedOutputs(ns)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to delete expired "+
|
||||
"locked outputs: %v", err)
|
||||
}
|
||||
assertOutputLocksExist(t, s, ns)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, db, teardown, err := testStore()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer teardown()
|
||||
|
||||
// Replace the store's default clock with a mock one in
|
||||
// order to simulate a real clock and speed up our
|
||||
// tests.
|
||||
store.clock = clock.NewTestClock(time.Time{})
|
||||
|
||||
// Add the spends we created above to the store.
|
||||
insertConfirmedCredit(t, store, db, confirmedTx, 0, block)
|
||||
insertUnconfirmedCredit(t, store, db, unconfirmedTx, 0)
|
||||
|
||||
// Run the test!
|
||||
commitDBTx(t, store, db, func(ns walletdb.ReadWriteBucket) {
|
||||
testCase.run(t, store, ns)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue