Merge pull request #696 from carlaKC/txn-labels

wtxmgr: add labels to transactions
This commit is contained in:
Olaoluwa Osuntokun 2020-05-11 17:12:28 -07:00 committed by GitHub
commit b07494fc2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 484 additions and 80 deletions

View file

@ -1378,7 +1378,7 @@ func sendPairs(w *wallet.Wallet, amounts map[string]btcutil.Amount,
if err != nil {
return "", err
}
tx, err := w.SendOutputs(outputs, account, minconf, feeSatPerKb)
tx, err := w.SendOutputs(outputs, account, minconf, feeSatPerKb, "")
if err != nil {
if err == txrules.ErrAmountNegative {
return "", ErrNeedPositiveAmount

View file

@ -519,7 +519,7 @@ func (s *walletServer) PublishTransaction(ctx context.Context, req *pb.PublishTr
"Bytes do not represent a valid raw transaction: %v", err)
}
err = s.wallet.PublishTransaction(&msgTx)
err = s.wallet.PublishTransaction(&msgTx, "")
if err != nil {
return nil, translateError(err)
}

View file

@ -6,16 +6,12 @@ package wallet
import (
"bytes"
"io/ioutil"
"os"
"testing"
"time"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcutil/hdkeychain"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/btcsuite/btcwallet/walletdb"
_ "github.com/btcsuite/btcwallet/walletdb/bdb"
@ -26,31 +22,8 @@ import (
// request a dry run of the txToOutputs call. It also makes sure a subsequent
// non-dry run call produces a similar transaction to the dry-run.
func TestTxToOutputsDryRun(t *testing.T) {
// Set up a wallet.
dir, err := ioutil.TempDir("", "createtx_test")
if err != nil {
t.Fatalf("Failed to create db dir: %v", err)
}
defer os.RemoveAll(dir)
seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes)
if err != nil {
t.Fatalf("unable to create seed: %v", err)
}
pubPass := []byte("hello")
privPass := []byte("world")
loader := NewLoader(&chaincfg.TestNet3Params, dir, true, 250)
w, err := loader.CreateNewWallet(pubPass, privPass, seed, time.Now())
if err != nil {
t.Fatalf("unable to create wallet: %v", err)
}
chainClient := &mockChainClient{}
w.chainClient = chainClient
if err := w.Unlock(privPass, time.After(10*time.Minute)); err != nil {
t.Fatalf("unable to unlock wallet: %v", err)
}
w, cleanup := testWallet(t)
defer cleanup()
// Create an address we can use to send some coins to.
addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0044)

47
wallet/example_test.go Normal file
View file

@ -0,0 +1,47 @@
package wallet
import (
"io/ioutil"
"os"
"testing"
"time"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcutil/hdkeychain"
)
// testWallet creates a test wallet and unlocks it.
func testWallet(t *testing.T) (*Wallet, func()) {
// Set up a wallet.
dir, err := ioutil.TempDir("", "test_wallet")
if err != nil {
t.Fatalf("Failed to create db dir: %v", err)
}
cleanup := func() {
if err := os.RemoveAll(dir); err != nil {
t.Fatalf("could not cleanup test: %v", err)
}
}
seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes)
if err != nil {
t.Fatalf("unable to create seed: %v", err)
}
pubPass := []byte("hello")
privPass := []byte("world")
loader := NewLoader(&chaincfg.TestNet3Params, dir, true, 250)
w, err := loader.CreateNewWallet(pubPass, privPass, seed, time.Now())
if err != nil {
t.Fatalf("unable to create wallet: %v", err)
}
chainClient := &mockChainClient{}
w.chainClient = chainClient
if err := w.Unlock(privPass, time.After(10*time.Minute)); err != nil {
t.Fatalf("unable to unlock wallet: %v", err)
}
return w, cleanup
}

View file

@ -148,6 +148,7 @@ func makeTxSummary(dbtx walletdb.ReadTx, w *Wallet, details *wtxmgr.TxDetails) T
MyOutputs: outputs,
Fee: fee,
Timestamp: details.Received.Unix(),
Label: details.Label,
}
}
@ -365,6 +366,7 @@ type TransactionSummary struct {
MyOutputs []TransactionSummaryOutput
Fee btcutil.Amount
Timestamp int64
Label string
}
// TransactionSummaryInput describes a transaction input that is relevant to the

View file

@ -65,6 +65,16 @@ var (
// down.
ErrWalletShuttingDown = errors.New("wallet shutting down")
// ErrUnknownTransaction is returned when an attempt is made to label
// a transaction that is not known to the wallet.
ErrUnknownTransaction = errors.New("cannot label transaction not " +
"known to wallet")
// ErrTxLabelExists is returned when a transaction already has a label
// and an attempt has been made to label it without setting overwrite
// to true.
ErrTxLabelExists = errors.New("transaction already labelled")
// Namespace bucket keys.
waddrmgrNamespaceKey = []byte("waddrmgr")
wtxmgrNamespaceKey = []byte("wtxmgr")
@ -1591,6 +1601,58 @@ func (w *Wallet) PubKeyForAddress(a btcutil.Address) (*btcec.PublicKey, error) {
return pubKey, err
}
// LabelTransaction adds a label to the transaction with the hash provided. The
// call will fail if the label is too long, or if the transaction already has
// a label and the overwrite boolean is not set.
func (w *Wallet) LabelTransaction(hash chainhash.Hash, label string,
overwrite bool) error {
// Check that the transaction is known to the wallet, and fail if it is
// unknown. If the transaction is known, check whether it already has
// a label.
err := walletdb.View(w.db, func(tx walletdb.ReadTx) error {
txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey)
dbTx, err := w.TxStore.TxDetails(txmgrNs, &hash)
if err != nil {
return err
}
// If the transaction looked up is nil, it was not found. We
// do not allow labelling of unknown transactions so we fail.
if dbTx == nil {
return ErrUnknownTransaction
}
_, err = wtxmgr.FetchTxLabel(txmgrNs, hash)
return err
})
switch err {
// If no labels have been written yet, we can silence the error.
// Likewise if there is no label, we do not need to do any overwrite
// checks.
case wtxmgr.ErrNoLabelBucket:
case wtxmgr.ErrTxLabelNotFound:
// If we successfully looked up a label, fail if the overwrite param
// is not set.
case nil:
if !overwrite {
return ErrTxLabelExists
}
// In another unrelated error occurred, return it.
default:
return err
}
return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error {
txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey)
return w.TxStore.PutTxLabel(txmgrNs, hash, label)
})
}
// PrivKeyForAddress looks up the associated private key for a P2PKH or P2PK
// address.
func (w *Wallet) PrivKeyForAddress(a btcutil.Address) (*btcec.PrivateKey, error) {
@ -3094,7 +3156,7 @@ func (w *Wallet) TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcu
// SendOutputs creates and sends payment transactions. It returns the
// transaction upon success.
func (w *Wallet) SendOutputs(outputs []*wire.TxOut, account uint32,
minconf int32, satPerKb btcutil.Amount) (*wire.MsgTx, error) {
minconf int32, satPerKb btcutil.Amount, label string) (*wire.MsgTx, error) {
// Ensure the outputs to be created adhere to the network's consensus
// rules.
@ -3118,7 +3180,7 @@ func (w *Wallet) SendOutputs(outputs []*wire.TxOut, account uint32,
return nil, err
}
txHash, err := w.reliablyPublishTransaction(createdTx.Tx)
txHash, err := w.reliablyPublishTransaction(createdTx.Tx, label)
if err != nil {
return nil, err
}
@ -3311,8 +3373,8 @@ func (e *ErrReplacement) Unwrap() error {
//
// This function is unstable and will be removed once syncing code is moved out
// of the wallet.
func (w *Wallet) PublishTransaction(tx *wire.MsgTx) error {
_, err := w.reliablyPublishTransaction(tx)
func (w *Wallet) PublishTransaction(tx *wire.MsgTx, label string) error {
_, err := w.reliablyPublishTransaction(tx, label)
return err
}
@ -3321,7 +3383,9 @@ func (w *Wallet) PublishTransaction(tx *wire.MsgTx) error {
// relevant database state, and finally possible removing the transaction from
// the database (along with cleaning up all inputs used, and outputs created) if
// the transaction is rejected by the backend.
func (w *Wallet) reliablyPublishTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) {
func (w *Wallet) reliablyPublishTransaction(tx *wire.MsgTx,
label string) (*chainhash.Hash, error) {
chainClient, err := w.requireChainClient()
if err != nil {
return nil, err
@ -3336,7 +3400,19 @@ func (w *Wallet) reliablyPublishTransaction(tx *wire.MsgTx) (*chainhash.Hash, er
return nil, err
}
err = walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error {
return w.addRelevantTx(dbTx, txRec, nil)
if err := w.addRelevantTx(dbTx, txRec, nil); err != nil {
return err
}
// If the tx label is empty, we can return early.
if len(label) == 0 {
return nil
}
// If there is a label we should write, get the namespace key
// and record it in the tx store.
txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey)
return w.TxStore.PutTxLabel(txmgrNs, tx.TxHash(), label)
})
if err != nil {
return nil, err

View file

@ -1,8 +1,20 @@
package wallet
import (
"encoding/hex"
"testing"
"time"
"github.com/btcsuite/btcwallet/walletdb"
"github.com/btcsuite/btcwallet/wtxmgr"
"github.com/btcsuite/btcutil"
)
var (
TstSerializedTx, _ = hex.DecodeString("010000000114d9ff358894c486b4ae11c2a8cf7851b1df64c53d2e511278eff17c22fb7373000000008c493046022100995447baec31ee9f6d4ec0e05cb2a44f6b817a99d5f6de167d1c75354a946410022100c9ffc23b64d770b0e01e7ff4d25fbc2f1ca8091053078a247905c39fce3760b601410458b8e267add3c1e374cf40f1de02b59213a82e1d84c2b94096e22e2f09387009c96debe1d0bcb2356ffdcf65d2a83d4b34e72c62eccd8490dbf2110167783b2bffffffff0280969800000000001976a914479ed307831d0ac19ebc5f63de7d5f1a430ddb9d88ac38bfaa00000000001976a914dadf9e3484f28b385ddeaa6c575c0c0d18e9788a88ac00000000")
TstTx, _ = btcutil.NewTxFromBytes(TstSerializedTx)
TstTxHash = TstTx.Hash()
)
// TestLocateBirthdayBlock ensures we can properly map a block in the chain to a
@ -83,3 +95,110 @@ func TestLocateBirthdayBlock(t *testing.T) {
}
}
}
// TestLabelTransaction tests labelling of transactions with invalid labels,
// and failure to label a transaction when it already has a label.
func TestLabelTransaction(t *testing.T) {
tests := []struct {
name string
// Whether the transaction should be known to the wallet.
txKnown bool
// Whether the test should write an existing label to disk.
existingLabel bool
// The overwrite parameter to call label transaction with.
overwrite bool
// The error we expect to be returned.
expectedErr error
}{
{
name: "existing label, not overwrite",
txKnown: true,
existingLabel: true,
overwrite: false,
expectedErr: ErrTxLabelExists,
},
{
name: "existing label, overwritten",
txKnown: true,
existingLabel: true,
overwrite: true,
expectedErr: nil,
},
{
name: "no prexisting label, ok",
txKnown: true,
existingLabel: false,
overwrite: false,
expectedErr: nil,
},
{
name: "transaction unknown",
txKnown: false,
existingLabel: false,
overwrite: false,
expectedErr: ErrUnknownTransaction,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
w, cleanup := testWallet(t)
defer cleanup()
// If the transaction should be known to the store, we
// write txdetail to disk.
if test.txKnown {
rec, err := wtxmgr.NewTxRecord(
TstSerializedTx, time.Now(),
)
if err != nil {
t.Fatal(err)
}
err = walletdb.Update(w.db,
func(tx walletdb.ReadWriteTx) error {
ns := tx.ReadWriteBucket(
wtxmgrNamespaceKey,
)
return w.TxStore.InsertTx(
ns, rec, nil,
)
})
if err != nil {
t.Fatalf("could not insert tx: %v", err)
}
}
// If we want to setup an existing label for the purpose
// of the test, write one to disk.
if test.existingLabel {
err := w.LabelTransaction(
*TstTxHash, "existing label", false,
)
if err != nil {
t.Fatalf("could not write label: %v",
err)
}
}
newLabel := "new label"
err := w.LabelTransaction(
*TstTxHash, newLabel, test.overwrite,
)
if err != test.expectedErr {
t.Fatalf("expected: %v, got: %v",
test.expectedErr, err)
}
})
}
}

View file

@ -62,6 +62,7 @@ var _ [32]byte = chainhash.Hash{}
var (
bucketBlocks = []byte("b")
bucketTxRecords = []byte("t")
bucketTxLabels = []byte("l")
bucketCredits = []byte("c")
bucketUnspent = []byte("u")
bucketDebits = []byte("d")

View file

@ -39,6 +39,7 @@ type TxDetails struct {
Block BlockMeta
Credits []CreditRecord
Debits []DebitRecord
Label string
}
// minedTxDetails fetches the TxDetails for the mined transaction with hash
@ -90,7 +91,17 @@ func (s *Store) minedTxDetails(ns walletdb.ReadBucket, txHash *chainhash.Hash, r
details.Debits = append(details.Debits, debIter.elem)
}
return &details, debIter.err
if debIter.err != nil {
return nil, debIter.err
}
// Finally, we add the transaction label to details.
details.Label, err = s.TxLabel(ns, *txHash)
if err != nil {
return nil, err
}
return &details, nil
}
// unminedTxDetails fetches the TxDetails for the unmined transaction with the
@ -158,9 +169,40 @@ func (s *Store) unminedTxDetails(ns walletdb.ReadBucket, txHash *chainhash.Hash,
})
}
// Finally, we add the transaction label to details.
details.Label, err = s.TxLabel(ns, *txHash)
if err != nil {
return nil, err
}
return &details, nil
}
// TxLabel looks up a transaction label for the txHash provided. If the store
// has no labels in it, or the specific txHash does not have a label, an empty
// string and no error are returned.
func (s *Store) TxLabel(ns walletdb.ReadBucket, txHash chainhash.Hash) (string,
error) {
label, err := FetchTxLabel(ns, txHash)
switch err {
// If there are no saved labels yet (the bucket has not been created) or
// there is not a label for this particular tx, we ignore the error.
case ErrNoLabelBucket:
fallthrough
case ErrTxLabelNotFound:
return "", nil
// If we found the label, we return it.
case nil:
return label, nil
}
// Otherwise, another error occurred while looking uo the label, so we
// return it.
return "", err
}
// TxDetails looks up all recorded details regarding a transaction with some
// hash. In case of a hash collision, the most recent transaction with a
// matching hash is returned.
@ -296,52 +338,13 @@ func (s *Store) rangeBlockTransactions(ns walletdb.ReadBucket, begin, end int32,
"block %v", txHash, block.Height)
return false, storeError(ErrData, str, nil)
}
detail := TxDetails{
Block: BlockMeta{
Block: block.Block,
Time: block.Time,
},
}
err := readRawTxRecord(&txHash, v, &detail.TxRecord)
detail, err := s.minedTxDetails(ns, &txHash, k, v)
if err != nil {
return false, err
}
credIter := makeReadCreditIterator(ns, k)
for credIter.next() {
if int(credIter.elem.Index) >= len(detail.MsgTx.TxOut) {
str := "saved credit index exceeds number of outputs"
return false, storeError(ErrData, str, nil)
}
// The credit iterator does not record whether
// this credit was spent by an unmined
// transaction, so check that here.
if !credIter.elem.Spent {
k := canonicalOutPoint(&txHash, credIter.elem.Index)
spent := existsRawUnminedInput(ns, k) != nil
credIter.elem.Spent = spent
}
detail.Credits = append(detail.Credits, credIter.elem)
}
if credIter.err != nil {
return false, credIter.err
}
debIter := makeReadDebitIterator(ns, k)
for debIter.next() {
if int(debIter.elem.Index) >= len(detail.MsgTx.TxIn) {
str := "saved debit index exceeds number of inputs"
return false, storeError(ErrData, str, nil)
}
detail.Debits = append(detail.Debits, debIter.elem)
}
if debIter.err != nil {
return false, debIter.err
}
details = append(details, detail)
details = append(details, *detail)
}
// Every block record must have at least one transaction, so it

View file

@ -7,6 +7,8 @@ package wtxmgr
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"time"
@ -18,6 +20,28 @@ import (
"github.com/btcsuite/btcwallet/walletdb"
)
// TxLabelLimit is the length limit we impose on transaction labels.
const TxLabelLimit = 500
var (
// ErrEmptyLabel is returned when an attempt to write a label that is
// empty is made.
ErrEmptyLabel = errors.New("empty transaction label not allowed")
// ErrLabelTooLong is returned when an attempt to write a label that is
// to long is made.
ErrLabelTooLong = errors.New("transaction label exceeds limit")
// ErrNoLabelBucket is returned when the bucket holding optional
// transaction labels is not found. This occurs when no transactions
// have been labelled yet.
ErrNoLabelBucket = errors.New("labels bucket does not exist")
// ErrTxLabelNotFound is returned when no label is found for a
// transaction hash.
ErrTxLabelNotFound = errors.New("label for transaction not found")
)
// Block contains the minimum amount of data to uniquely identify any block on
// either the best or side chain.
type Block struct {
@ -935,3 +959,70 @@ func (s *Store) Balance(ns walletdb.ReadBucket, minConf int32, syncHeight int32)
return bal, nil
}
// PutTxLabel validates transaction labels and writes them to disk if they
// are non-zero and within the label length limit. The entry is keyed by the
// transaction hash:
// [0:32] Transaction hash (32 bytes)
//
// The label itself is written to disk in length value format:
// [0:2] Label length
// [2: +len] Label
func (s *Store) PutTxLabel(ns walletdb.ReadWriteBucket, txid chainhash.Hash,
label string) error {
if len(label) == 0 {
return ErrEmptyLabel
}
if len(label) > TxLabelLimit {
return ErrLabelTooLong
}
labelBucket, err := ns.CreateBucketIfNotExists(bucketTxLabels)
if err != nil {
return err
}
// We expect the label length to be limited on creation, so we can
// store the label's length as a uint16.
labelLen := uint16(len(label))
var buf bytes.Buffer
var b [2]byte
binary.BigEndian.PutUint16(b[:], labelLen)
if _, err := buf.Write(b[:]); err != nil {
return err
}
if _, err := buf.WriteString(label); err != nil {
return err
}
return labelBucket.Put(txid[:], buf.Bytes())
}
// FetchTxLabel reads a transaction label from the tx labels bucket. If a label
// with 0 length was written, we return an error, since this is unexpected.
func FetchTxLabel(ns walletdb.ReadBucket, txid chainhash.Hash) (string, error) {
labelBucket := ns.NestedReadBucket(bucketTxLabels)
if labelBucket == nil {
return "", ErrNoLabelBucket
}
v := labelBucket.Get(txid[:])
if v == nil {
return "", ErrTxLabelNotFound
}
// If the label is empty, return an error.
length := binary.BigEndian.Uint16(v[0:2])
if length == 0 {
return "", ErrEmptyLabel
}
// Read the remainder of the bytes into a label string.
label := string(v[2:])
return label, nil
}

View file

@ -2263,3 +2263,95 @@ func TestInsertMempoolTxAndConfirm(t *testing.T) {
}
})
}
// TestTxLabel tests reading and writing of transaction labels.
func TestTxLabel(t *testing.T) {
t.Parallel()
store, db, teardown, err := testStore()
if err != nil {
t.Fatal(err)
}
defer teardown()
// txid is the transaction hash we will use to write and get labels for.
txid := TstRecvTx.Hash()
// txidNotFound is distinct from txid, and will not have a label written
// to disk.
txidNotFound := TstSpendingTx.Hash()
// getBucket gets the top level bucket, and fails the test if it is
// not found.
getBucket := func(tx walletdb.ReadWriteTx) walletdb.ReadWriteBucket {
testBucket := tx.ReadWriteBucket(namespaceKey)
if testBucket == nil {
t.Fatalf("could not get bucket: %v", err)
}
return testBucket
}
// tryPutLabel attempts to write a label to disk.
tryPutLabel := func(label string) error {
return walletdb.Update(db, func(tx walletdb.ReadWriteTx) error {
// Try to write the label to disk.
return store.PutTxLabel(getBucket(tx), *txid, label)
})
}
// tryReadLabel attempts to retrieve a label for a given txid.
tryReadLabel := func(labelTx chainhash.Hash) (string, error) {
var label string
err := walletdb.Update(db, func(tx walletdb.ReadWriteTx) error {
var err error
label, err = FetchTxLabel(getBucket(tx), labelTx)
return err
})
return label, err
}
// First, try to lookup a label when the labels bucket does not exist
// yet.
_, err = tryReadLabel(*txid)
if err != ErrNoLabelBucket {
t.Fatalf("expected: %v, got: %v", ErrNoLabelBucket, err)
}
// Now try to write an empty label, which should fail.
err = tryPutLabel("")
if err != ErrEmptyLabel {
t.Fatalf("expected: %v, got: %v", ErrEmptyLabel, err)
}
// Create a label which exceeds the length limit.
longLabel := make([]byte, TxLabelLimit+1)
err = tryPutLabel(string(longLabel))
if err != ErrLabelTooLong {
t.Fatalf("expected: %v, got: %v", ErrLabelTooLong, err)
}
// Write an acceptable length label to disk, this should succeed.
testLabel := "test label"
err = tryPutLabel(testLabel)
if err != nil {
t.Fatalf("expected: no error, got: %v", err)
}
diskLabel, err := tryReadLabel(*txid)
if err != nil {
t.Fatalf("expected: no error, got: %v", err)
}
if diskLabel != testLabel {
t.Fatalf("expected: %v, got: %v", testLabel, diskLabel)
}
// Finally, try to read a label for a transaction which does not have
// one.
_, err = tryReadLabel(*txidNotFound)
if err != ErrTxLabelNotFound {
t.Fatalf("expected: %v, got: %v", ErrTxLabelNotFound, err)
}
}