From 628e2c0dff562d2117754f07689c2c8a1186b836 Mon Sep 17 00:00:00 2001 From: JoeGruffins <34998433+JoeGruffins@users.noreply.github.com> Date: Mon, 19 Feb 2024 18:46:08 +0000 Subject: [PATCH] multi: Add FindBond to Bonder. (#2613) When the server tells us of an unknown v0 bond attempt to find and recreate it so that it can be refunded later. Useful when restoring from seed. --- client/asset/btc/btc.go | 137 +++++++++++++++++++++++ client/asset/btc/btc_test.go | 207 +++++++++++++++++++++++++++++++---- client/asset/btc/spv_test.go | 18 +-- client/asset/dcr/dcr.go | 108 ++++++++++++++++++ client/asset/dcr/dcr_test.go | 151 +++++++++++++++++++++++++ client/asset/interface.go | 13 +++ client/core/bond.go | 4 +- client/core/core.go | 119 +++++++++++++++++++- client/core/core_test.go | 95 ++++++++++++++++ client/core/locale_ntfn.go | 7 ++ client/core/notification.go | 10 ++ client/core/wallet.go | 10 ++ 12 files changed, 845 insertions(+), 34 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 0544e66bd5..f48dfe584f 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -5098,6 +5098,143 @@ func (btc *baseWallet) RefundBond(ctx context.Context, ver uint16, coinID, scrip return NewOutput(txHash, 0, uint64(msgTx.TxOut[0].Value)), nil } +func (btc *baseWallet) decodeV0BondTx(msgTx *wire.MsgTx, txHash *chainhash.Hash, coinID []byte) (*asset.BondDetails, error) { + if len(msgTx.TxOut) < 2 { + return nil, fmt.Errorf("tx %s is not a v0 bond transaction: too few outputs", txHash) + } + _, lockTime, pkh, err := dexbtc.ExtractBondCommitDataV0(0, msgTx.TxOut[1].PkScript) + if err != nil { + return nil, fmt.Errorf("unable to extract bond commitment details from output 1 of %s: %v", txHash, err) + } + // Sanity check. + bondScript, err := dexbtc.MakeBondScript(0, lockTime, pkh[:]) + if err != nil { + return nil, fmt.Errorf("failed to build bond output redeem script: %w", err) + } + pkScript, err := btc.scriptHashScript(bondScript) + if err != nil { + return nil, fmt.Errorf("error constructing p2sh script: %v", err) + } + if !bytes.Equal(pkScript, msgTx.TxOut[0].PkScript) { + return nil, fmt.Errorf("bond script does not match commit data for %s: %x != %x", + txHash, bondScript, msgTx.TxOut[0].PkScript) + } + return &asset.BondDetails{ + Bond: &asset.Bond{ + Version: 0, + AssetID: btc.cloneParams.AssetID, + Amount: uint64(msgTx.TxOut[0].Value), + CoinID: coinID, + Data: bondScript, + // + // SignedTx and UnsignedTx not populated because this is + // an already posted bond and these fields are no longer used. + // SignedTx, UnsignedTx []byte + // + // RedeemTx cannot be populated because we do not have + // the private key that only core knows. Core will need + // the BondPKH to determine what the private key was. + // RedeemTx []byte + }, + LockTime: time.Unix(int64(lockTime), 0), + CheckPrivKey: func(bondKey *secp256k1.PrivateKey) bool { + pk := bondKey.PubKey().SerializeCompressed() + pkhB := btcutil.Hash160(pk) + return bytes.Equal(pkh[:], pkhB) + }, + }, nil +} + +// FindBond finds the bond with coinID and returns the values used to create it. +func (btc *baseWallet) FindBond(_ context.Context, coinID []byte, _ time.Time) (bond *asset.BondDetails, err error) { + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return nil, err + } + + // If the bond was funded by this wallet or had a change output paying + // to this wallet, it should be found here. + tx, err := btc.node.getWalletTransaction(txHash) + if err != nil { + return nil, fmt.Errorf("did not find the bond output %v:%d", txHash, vout) + } + msgTx, err := btc.deserializeTx(tx.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) + } + return btc.decodeV0BondTx(msgTx, txHash, coinID) +} + +// FindBond finds the bond with coinID and returns the values used to create it. +// The intermediate wallet is able to brute force finding blocks. +func (btc *intermediaryWallet) FindBond( + ctx context.Context, + coinID []byte, + searchUntil time.Time, +) (bond *asset.BondDetails, err error) { + + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return nil, err + } + + // If the bond was funded by this wallet or had a change output paying + // to this wallet, it should be found here. + tx, err := btc.node.getWalletTransaction(txHash) + if err == nil { + msgTx, err := btc.deserializeTx(tx.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) + } + return btc.decodeV0BondTx(msgTx, txHash, coinID) + } + if !errors.Is(err, asset.CoinNotFoundError) { + btc.log.Warnf("Unexpected error looking up bond output %v:%d", txHash, vout) + } + + // The bond was not funded by this wallet or had no change output when + // restored from seed. This is not a problem. However, we are unable to + // use filters because we don't know any output scripts. Brute force + // finding the transaction. + bestBlockHdr, err := btc.node.getBestBlockHeader() + if err != nil { + return nil, fmt.Errorf("unable to get best hash: %v", err) + } + blockHash, err := chainhash.NewHashFromStr(bestBlockHdr.Hash) + if err != nil { + return nil, fmt.Errorf("invalid best block hash from %s node: %v", btc.symbol, err) + } + var ( + blk *wire.MsgBlock + msgTx *wire.MsgTx + ) +out: + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("bond search stopped: %w", err) + } + blk, err = btc.tipRedeemer.getBlock(*blockHash) + if err != nil { + return nil, fmt.Errorf("error retrieving block %s: %w", blockHash, err) + } + if blk.Header.Timestamp.Before(searchUntil) { + return nil, fmt.Errorf("searched blocks until %v but did not find the bond tx %s", searchUntil, txHash) + } + for _, tx := range blk.Transactions { + if tx.TxHash() == *txHash { + btc.log.Debugf("Found mined tx %s in block %s.", txHash, blk.BlockHash()) + msgTx = tx + break out + } + } + blockHash = &blk.Header.PrevBlock + if blockHash == nil { + return nil, fmt.Errorf("did not find the bond output %v:%d", txHash, vout) + } + } + return btc.decodeV0BondTx(msgTx, txHash, coinID) +} + // BondsFeeBuffer suggests how much extra may be required for the transaction // fees part of required bond reserves when bond rotation is enabled. The // provided fee rate may be zero, in which case the wallet will use it's own diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 9ea90088d6..01ee5c84fd 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -142,7 +142,7 @@ type testData struct { signMsgFunc func([]json.RawMessage) (json.RawMessage, error) blockchainMtx sync.RWMutex - verboseBlocks map[string]*msgBlockWithHeight + verboseBlocks map[chainhash.Hash]*msgBlockWithHeight dbBlockForTx map[chainhash.Hash]*hashEntry mainchain map[int64]*chainhash.Hash getBlockchainInfo *GetBlockchainInfoResult @@ -198,8 +198,8 @@ func newTestData() *testData { genesisHash := chaincfg.MainNetParams.GenesisHash return &testData{ txOutRes: newTxOutResult([]byte{}, 1, 0), - verboseBlocks: map[string]*msgBlockWithHeight{ - genesisHash.String(): {msgBlock: &wire.MsgBlock{}}, + verboseBlocks: map[chainhash.Hash]*msgBlockWithHeight{ + *genesisHash: {msgBlock: &wire.MsgBlock{}}, }, dbBlockForTx: make(map[chainhash.Hash]*hashEntry), mainchain: map[int64]*chainhash.Hash{ @@ -215,7 +215,7 @@ func newTestData() *testData { } } -func (c *testData) getBlock(blockHash string) *msgBlockWithHeight { +func (c *testData) getBlock(blockHash chainhash.Hash) *msgBlockWithHeight { c.blockchainMtx.Lock() defer c.blockchainMtx.Unlock() return c.verboseBlocks[blockHash] @@ -380,8 +380,11 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js if err != nil { return nil, err } - - blk, found := c.verboseBlocks[blockHashStr] + blkHash, err := chainhash.NewHashFromStr(blockHashStr) + if err != nil { + return nil, err + } + blk, found := c.verboseBlocks[*blkHash] if !found { return nil, fmt.Errorf("block not found") } @@ -393,9 +396,13 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js return json.Marshal(hex.EncodeToString(buf.Bytes())) case methodGetBlockHeader: - var blkHash string - _ = json.Unmarshal(params[0], &blkHash) - block := c.getBlock(blkHash) + var blkHashStr string + _ = json.Unmarshal(params[0], &blkHashStr) + blkHash, err := chainhash.NewHashFromStr(blkHashStr) + if err != nil { + return nil, err + } + block := c.getBlock(*blkHash) if block == nil { return nil, fmt.Errorf("no block verbose found") } @@ -403,7 +410,7 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js c.blockchainMtx.RLock() defer c.blockchainMtx.RUnlock() return json.Marshal(&BlockHeader{ - Hash: block.msgBlock.BlockHash().String(), + Hash: blkHash.String(), Height: block.height, // Confirmations: block.Confirmations, // Time: block.Time, @@ -515,13 +522,15 @@ func (c *testData) addRawTx(blockHeight int64, tx *wire.MsgTx) (*chainhash.Hash, msgBlock := wire.NewMsgBlock(header) // only now do we know the block hash hash := msgBlock.BlockHash() blockHash = &hash - c.verboseBlocks[blockHash.String()] = &msgBlockWithHeight{ + c.verboseBlocks[*blockHash] = &msgBlockWithHeight{ msgBlock: msgBlock, height: blockHeight, } c.mainchain[blockHeight] = blockHash } - block := c.verboseBlocks[blockHash.String()] + block := c.verboseBlocks[*blockHash] + // NOTE: Adding a transaction changes the msgBlock.BlockHash() so the + // map key is technically always wrong. block.msgBlock.AddTransaction(tx) return blockHash, block.msgBlock } @@ -539,7 +548,7 @@ func (c *testData) getBlockAtHeight(blockHeight int64) (*chainhash.Hash, *msgBlo if !found { return nil, nil } - blk := c.verboseBlocks[blockHash.String()] + blk := c.verboseBlocks[*blockHash] return blockHash, blk } @@ -547,7 +556,7 @@ func (c *testData) truncateChains() { c.blockchainMtx.RLock() defer c.blockchainMtx.RUnlock() c.mainchain = make(map[int64]*chainhash.Hash) - c.verboseBlocks = make(map[string]*msgBlockWithHeight) + c.verboseBlocks = make(map[chainhash.Hash]*msgBlockWithHeight) c.mempoolTxs = make(map[chainhash.Hash]*wire.MsgTx) } @@ -763,6 +772,11 @@ func TestFundMultiOrder(t *testing.T) { runRubric(t, testFundMultiOrder) } +func decodeString(s string) []byte { + b, _ := hex.DecodeString(s) + return b +} + func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { wallet, node, shutdown := tNewWallet(segwit, walletType) defer shutdown() @@ -773,11 +787,6 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { txIDs := make([]string, 0, 5) txHashes := make([]*chainhash.Hash, 0, 5) - decodeString := func(s string) []byte { - b, _ := hex.DecodeString(s) - return b - } - addresses_legacy := []string{ "n235HrCqx9EcS7teHcJEAthoBF5gvtrAoy", "mfjtHyu163DW5ZJXRHkY6kMLtHyWHTH6Qx", @@ -4732,7 +4741,7 @@ func testAccelerateOrder(t *testing.T, segwit bool, walletType string) { var blockHash100 chainhash.Hash copy(blockHash100[:], encode.RandomBytes(32)) - node.verboseBlocks[blockHash100.String()] = &msgBlockWithHeight{height: 100, msgBlock: &wire.MsgBlock{ + node.verboseBlocks[blockHash100] = &msgBlockWithHeight{height: 100, msgBlock: &wire.MsgBlock{ Header: wire.BlockHeader{Timestamp: time.Now()}, }} node.mainchain[100] = &blockHash100 @@ -6005,3 +6014,161 @@ func TestAddressRecycling(t *testing.T) { } } + +func TestFindBond(t *testing.T) { + wallet, node, shutdown := tNewWallet(false, walletTypeRPC) + defer shutdown() + + node.signFunc = func(tx *wire.MsgTx) { + signFunc(tx, 0, wallet.segwit) + } + + privBytes, _ := hex.DecodeString("b07209eec1a8fb6cfe5cb6ace36567406971a75c330db7101fb21bc679bc5330") + bondKey, _ := btcec.PrivKeyFromBytes(privBytes) + + amt := uint64(500_000) + acctID := [32]byte{} + lockTime := time.Now().Add(time.Hour * 12) + utxo := &ListUnspentResult{ + TxID: tTxID, + Address: tP2PKHAddr, + Amount: 1.0, + Confirmations: 1, + Spendable: true, + ScriptPubKey: decodeString("76a914e114d5bb20cdbd75f3726f27c10423eb1332576288ac"), + } + node.listUnspent = []*ListUnspentResult{utxo} + node.changeAddr = tP2PKHAddr + + bond, _, err := wallet.MakeBondTx(0, amt, 200, lockTime, bondKey, acctID[:]) + if err != nil { + t.Fatal(err) + } + + txRes := func(tx []byte) *GetTransactionResult { + return &GetTransactionResult{ + BlockHash: hex.EncodeToString(randBytes(32)), + Bytes: tx, + } + } + + newBondTx := func() *wire.MsgTx { + msgTx, err := msgTxFromBytes(bond.SignedTx) + if err != nil { + t.Fatal(err) + } + return msgTx + } + tooFewOutputs := newBondTx() + tooFewOutputs.TxOut = tooFewOutputs.TxOut[2:] + tooFewOutputsBytes, err := serializeMsgTx(tooFewOutputs) + if err != nil { + t.Fatal(err) + } + + badBondScript := newBondTx() + badBondScript.TxOut[1].PkScript = badBondScript.TxOut[1].PkScript[1:] + badBondScriptBytes, err := serializeMsgTx(badBondScript) + if err != nil { + t.Fatal(err) + } + + noBondMatch := newBondTx() + noBondMatch.TxOut[0].PkScript = noBondMatch.TxOut[0].PkScript[1:] + noBondMatchBytes, err := serializeMsgTx(noBondMatch) + if err != nil { + t.Fatal(err) + } + + node.addRawTx(1, newBondTx()) + verboseBlocks := node.verboseBlocks + for _, blk := range verboseBlocks { + blk.msgBlock.Header.Timestamp = time.Now() + } + + tests := []struct { + name string + coinID []byte + txRes *GetTransactionResult + bestBlockErr error + getTransactionErr error + verboseBlocks map[chainhash.Hash]*msgBlockWithHeight + searchUntil time.Time + wantErr bool + }{{ + name: "ok", + coinID: bond.CoinID, + txRes: txRes(bond.SignedTx), + }, { + name: "ok with find blocks", + coinID: bond.CoinID, + getTransactionErr: asset.CoinNotFoundError, + }, { + name: "bad coin id", + coinID: make([]byte, 0), + txRes: txRes(bond.SignedTx), + wantErr: true, + }, { + name: "missing an output", + coinID: bond.CoinID, + txRes: txRes(tooFewOutputsBytes), + wantErr: true, + }, { + name: "bad bond commitment script", + coinID: bond.CoinID, + txRes: txRes(badBondScriptBytes), + wantErr: true, + }, { + name: "bond script does not match commitment", + coinID: bond.CoinID, + txRes: txRes(noBondMatchBytes), + wantErr: true, + }, { + name: "bad msgtx", + coinID: bond.CoinID, + txRes: txRes(bond.SignedTx[1:]), + wantErr: true, + }, { + name: "get best block error", + coinID: bond.CoinID, + getTransactionErr: asset.CoinNotFoundError, + bestBlockErr: errors.New("some error"), + wantErr: true, + }, { + name: "block not found", + coinID: bond.CoinID, + getTransactionErr: asset.CoinNotFoundError, + verboseBlocks: map[chainhash.Hash]*msgBlockWithHeight{}, + wantErr: true, + }, { + name: "did not find by search until time", + coinID: bond.CoinID, + getTransactionErr: asset.CoinNotFoundError, + searchUntil: time.Now().Add(time.Hour), + wantErr: true, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + node.getTransactionMap["any"] = test.txRes + node.getBestBlockHashErr = test.bestBlockErr + node.verboseBlocks = verboseBlocks + if test.verboseBlocks != nil { + node.verboseBlocks = test.verboseBlocks + } + bd, err := wallet.FindBond(tCtx, test.coinID, test.searchUntil) + if test.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bd.CheckPrivKey(bondKey) { + t.Fatal("pkh not equal") + } + }) + } +} diff --git a/client/asset/btc/spv_test.go b/client/asset/btc/spv_test.go index 80fe911cf0..3fe9ba8d57 100644 --- a/client/asset/btc/spv_test.go +++ b/client/asset/btc/spv_test.go @@ -247,7 +247,7 @@ func (c *tBtcWallet) WalletTransaction(txHash *chainhash.Hash) (*wtxmgr.TxDetail tx, _ := msgTxFromBytes(txData.Bytes) blockHash, _ := chainhash.NewHashFromStr(txData.BlockHash) - blk := c.getBlock(txData.BlockHash) + blk := c.getBlock(*blockHash) var blockHeight int32 if blk != nil { blockHeight = int32(blk.height) @@ -313,7 +313,7 @@ func (c *tBtcWallet) getTransaction(txHash *chainhash.Hash) (*GetTransactionResu func (c *tBtcWallet) SyncedTo() waddrmgr.BlockStamp { bestHash, bestHeight := c.bestBlock() // NOTE: in reality this may be lower than the chain service's best block - blk := c.getBlock(bestHash.String()) + blk := c.getBlock(*bestHash) return waddrmgr.BlockStamp{ Height: int32(bestHeight), Hash: *bestHash, @@ -387,7 +387,7 @@ func (c *tNeutrinoClient) BestBlock() (*headerfs.BlockStamp, error) { } c.blockchainMtx.RUnlock() bestHash, bestHeight := c.bestBlock() - blk := c.getBlock(bestHash.String()) + blk := c.getBlock(*bestHash) return &headerfs.BlockStamp{ Height: int32(bestHeight), Hash: *bestHash, @@ -410,7 +410,7 @@ func (c *tNeutrinoClient) AddPeer(string) error { } func (c *tNeutrinoClient) GetBlockHeight(hash *chainhash.Hash) (int32, error) { - block := c.getBlock(hash.String()) + block := c.getBlock(*hash) if block == nil { return 0, fmt.Errorf("(test) block not found for block hash %s", hash) } @@ -418,7 +418,7 @@ func (c *tNeutrinoClient) GetBlockHeight(hash *chainhash.Hash) (int32, error) { } func (c *tNeutrinoClient) GetBlockHeader(blkHash *chainhash.Hash) (*wire.BlockHeader, error) { - block := c.getBlock(blkHash.String()) + block := c.getBlock(*blkHash) if block == nil { return nil, errors.New("no block verbose found") } @@ -434,7 +434,7 @@ func (c *tNeutrinoClient) GetCFilter(blockHash chainhash.Hash, filterType wire.F } func (c *tNeutrinoClient) GetBlock(blockHash chainhash.Hash, options ...neutrino.QueryOption) (*btcutil.Block, error) { - blk := c.getBlock(blockHash.String()) + blk := c.getBlock(blockHash) if blk == nil { return nil, fmt.Errorf("no (test) block %s", blockHash) } @@ -860,7 +860,7 @@ func TestGetBlockHeader(t *testing.T) { blockHash = h } - node.verboseBlocks[h.String()] = hdr + node.verboseBlocks[h] = hdr hh := h // just because we are storing pointers in mainchain node.mainchain[int64(height)] = &hh } @@ -902,11 +902,11 @@ func TestGetBlockHeader(t *testing.T) { node.mainchain[int64(blockHeight)] = &blockHash // clean up // Can't fetch header error. - delete(node.verboseBlocks, blockHash.String()) // can't find block by hash + delete(node.verboseBlocks, blockHash) // can't find block by hash if _, _, err := wallet.tipRedeemer.getBlockHeader(&blockHash); err == nil { t.Fatalf("Can't fetch header error not propagated") } - node.verboseBlocks[blockHash.String()] = &blockHdr // clean up + node.verboseBlocks[blockHash] = &blockHdr // clean up // Main chain is shorter than requested block. prevMainchain := node.mainchain diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index ed5ecd96d5..5e3c5b6a4d 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -4389,6 +4389,114 @@ func (dcr *ExchangeWallet) RefundBond(ctx context.Context, ver uint16, coinID, s */ } +// FindBond finds the bond with coinID and returns the values used to create it. +func (dcr *ExchangeWallet) FindBond(ctx context.Context, coinID []byte, searchUntil time.Time) (bond *asset.BondDetails, err error) { + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return nil, err + } + + decodeV0BondTx := func(msgTx *wire.MsgTx) (*asset.BondDetails, error) { + if len(msgTx.TxOut) < 2 { + return nil, fmt.Errorf("tx %s is not a v0 bond transaction: too few outputs", txHash) + } + _, lockTime, pkh, err := dexdcr.ExtractBondCommitDataV0(0, msgTx.TxOut[1].PkScript) + if err != nil { + return nil, fmt.Errorf("unable to extract bond commitment details from output 1 of %s: %v", txHash, err) + } + // Sanity check. + bondScript, err := dexdcr.MakeBondScript(0, lockTime, pkh[:]) + if err != nil { + return nil, fmt.Errorf("failed to build bond output redeem script: %w", err) + } + bondAddr, err := stdaddr.NewAddressScriptHash(0, bondScript, dcr.chainParams) + if err != nil { + return nil, fmt.Errorf("failed to build bond output payment script: %w", err) + } + _, bondScriptWOpcodes := bondAddr.PaymentScript() + if !bytes.Equal(bondScriptWOpcodes, msgTx.TxOut[0].PkScript) { + return nil, fmt.Errorf("bond script does not match commit data for %s: %x != %x", + txHash, bondScript, msgTx.TxOut[0].PkScript) + } + return &asset.BondDetails{ + Bond: &asset.Bond{ + Version: 0, + AssetID: BipID, + Amount: uint64(msgTx.TxOut[0].Value), + CoinID: coinID, + Data: bondScript, + // + // SignedTx and UnsignedTx not populated because this is + // an already posted bond and these fields are no longer used. + // SignedTx, UnsignedTx []byte + // + // RedeemTx cannot be populated because we do not have + // the private key that only core knows. Core will need + // the BondPKH to determine what the private key was. + // RedeemTx []byte + }, + LockTime: time.Unix(int64(lockTime), 0), + CheckPrivKey: func(bondKey *secp256k1.PrivateKey) bool { + pk := bondKey.PubKey().SerializeCompressed() + pkhB := stdaddr.Hash160(pk) + return bytes.Equal(pkh[:], pkhB) + }, + }, nil + } + + // If the bond was funded by this wallet or had a change output paying + // to this wallet, it should be found here. + tx, err := dcr.wallet.GetTransaction(ctx, txHash) + if err == nil { + msgTx, err := msgTxFromHex(tx.Hex) + if err != nil { + return nil, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) + } + return decodeV0BondTx(msgTx) + } + if !errors.Is(err, asset.CoinNotFoundError) { + dcr.log.Warnf("Unexpected error looking up bond output %v:%d", txHash, vout) + } + + // The bond was not funded by this wallet or had no change output when + // restored from seed. This is not a problem. However, we are unable to + // use filters because we don't know any output scripts. Brute force + // finding the transaction. + blockHash, _, err := dcr.wallet.GetBestBlock(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get best hash: %v", err) + } + var ( + blk *wire.MsgBlock + msgTx *wire.MsgTx + ) +out: + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("bond search stopped: %w", err) + } + blk, err = dcr.wallet.GetBlock(ctx, blockHash) + if err != nil { + return nil, fmt.Errorf("error retrieving block %s: %w", blockHash, err) + } + if blk.Header.Timestamp.Before(searchUntil) { + return nil, fmt.Errorf("searched blocks until %v but did not find the bond tx %s", searchUntil, txHash) + } + for _, tx := range blk.Transactions { + if tx.TxHash() == *txHash { + dcr.log.Debugf("Found mined tx %s in block %s.", txHash, blk.BlockHash()) + msgTx = tx + break out + } + } + blockHash = &blk.Header.PrevBlock + if blockHash == nil { + return nil, fmt.Errorf("did not find the bond tx %s", txHash) + } + } + return decodeV0BondTx(msgTx) +} + // SendTransaction broadcasts a valid fully-signed transaction. func (dcr *ExchangeWallet) SendTransaction(rawTx []byte) ([]byte, error) { msgTx, err := msgTxFromBytes(rawTx) diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 242c5c69ed..db157e20eb 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -4537,3 +4537,154 @@ func TestPurchaseTickets(t *testing.T) { buyTickets(1, true) checkRemains(1, 0) } + +func TestFindBond(t *testing.T) { + wallet, node, shutdown := tNewWallet() + defer shutdown() + + privBytes, _ := hex.DecodeString("b07209eec1a8fb6cfe5cb6ace36567406971a75c330db7101fb21bc679bc5330") + bondKey := secp256k1.PrivKeyFromBytes(privBytes) + + amt := uint64(50_000) + acctID := [32]byte{} + lockTime := time.Now().Add(time.Hour * 12) + utxo := walletjson.ListUnspentResult{ + TxID: tTxID, + Address: tPKHAddr.String(), + Account: tAcctName, + Amount: 1.0, + Confirmations: 1, + ScriptPubKey: hex.EncodeToString(tP2PKHScript), + Spendable: true, + } + node.unspent = []walletjson.ListUnspentResult{utxo} + node.changeAddr = tPKHAddr + + bond, _, err := wallet.MakeBondTx(0, amt, 200, lockTime, bondKey, acctID[:]) + if err != nil { + t.Fatal(err) + } + + txFn := func(err error, tx []byte) func() (*walletjson.GetTransactionResult, error) { + return func() (*walletjson.GetTransactionResult, error) { + if err != nil { + return nil, err + } + h := hex.EncodeToString(tx) + return &walletjson.GetTransactionResult{ + BlockHash: hex.EncodeToString(randBytes(32)), + Hex: h, + }, nil + } + } + + newBondTx := func() *wire.MsgTx { + msgTx := wire.NewMsgTx() + if err := msgTx.FromBytes(bond.SignedTx); err != nil { + t.Fatal(err) + } + return msgTx + } + tooFewOutputs := newBondTx() + tooFewOutputs.TxOut = tooFewOutputs.TxOut[2:] + tooFewOutputsBytes, err := tooFewOutputs.Bytes() + if err != nil { + t.Fatal(err) + } + + badBondScript := newBondTx() + badBondScript.TxOut[1].PkScript = badBondScript.TxOut[1].PkScript[1:] + badBondScriptBytes, err := badBondScript.Bytes() + if err != nil { + t.Fatal(err) + } + + noBondMatch := newBondTx() + noBondMatch.TxOut[0].PkScript = noBondMatch.TxOut[0].PkScript[1:] + noBondMatchBytes, err := noBondMatch.Bytes() + if err != nil { + t.Fatal(err) + } + + node.blockchain.addRawTx(1, newBondTx()) + verboseBlocks := node.blockchain.verboseBlocks + + tests := []struct { + name string + coinID []byte + txRes func() (*walletjson.GetTransactionResult, error) + bestBlockErr error + verboseBlocks map[chainhash.Hash]*wire.MsgBlock + searchUntil time.Time + wantErr bool + }{{ + name: "ok", + coinID: bond.CoinID, + txRes: txFn(nil, bond.SignedTx), + }, { + name: "ok with find blocks", + coinID: bond.CoinID, + txRes: txFn(asset.CoinNotFoundError, nil), + }, { + name: "bad coin id", + coinID: make([]byte, 0), + txRes: txFn(nil, bond.SignedTx), + wantErr: true, + }, { + name: "missing an output", + coinID: bond.CoinID, + txRes: txFn(nil, tooFewOutputsBytes), + wantErr: true, + }, { + name: "bad bond commitment script", + coinID: bond.CoinID, + txRes: txFn(nil, badBondScriptBytes), + wantErr: true, + }, { + name: "bond script does not match commitment", + coinID: bond.CoinID, + txRes: txFn(nil, noBondMatchBytes), + wantErr: true, + }, { + name: "bad msgtx", + coinID: bond.CoinID, + txRes: txFn(nil, bond.SignedTx[1:]), + wantErr: true, + }, { + name: "get best block error", + coinID: bond.CoinID, + txRes: txFn(asset.CoinNotFoundError, nil), + bestBlockErr: errors.New("some error"), + wantErr: true, + }, { + name: "block not found", + coinID: bond.CoinID, + txRes: txFn(asset.CoinNotFoundError, nil), + verboseBlocks: map[chainhash.Hash]*wire.MsgBlock{}, + wantErr: true, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + node.walletTxFn = test.txRes + node.bestBlockErr = test.bestBlockErr + node.blockchain.verboseBlocks = verboseBlocks + if test.verboseBlocks != nil { + node.blockchain.verboseBlocks = test.verboseBlocks + } + bd, err := wallet.FindBond(tCtx, test.coinID, test.searchUntil) + if test.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bd.CheckPrivKey(bondKey) { + t.Fatal("pkh not equal") + } + }) + } +} diff --git a/client/asset/interface.go b/client/asset/interface.go index 929065bb7c..fd08cdbefa 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -585,6 +585,13 @@ type Broadcaster interface { SendTransaction(rawTx []byte) ([]byte, error) } +// BondDetails is the return from Bonder.FindBond. +type BondDetails struct { + *Bond + LockTime time.Time + CheckPrivKey func(priv *secp256k1.PrivateKey) bool +} + // Bonder is a wallet capable of creating and redeeming time-locked fidelity // bond transaction outputs. type Bonder interface { @@ -615,6 +622,12 @@ type Bonder interface { // private key to spend it. The bond is broadcasted. RefundBond(ctx context.Context, ver uint16, coinID, script []byte, amt uint64, privKey *secp256k1.PrivateKey) (Coin, error) + // FindBond finds the bond with coinID and returns the values used to + // create it. The output should be unspent with the lockTime set to + // some time in the future. searchUntil is used for some wallets that + // are able to pull blocks. + FindBond(ctx context.Context, coinID []byte, searchUntil time.Time) (bondDetails *BondDetails, err error) + // A RefundBondByCoinID may be created in the future to attempt to refund a // bond by locating it on chain, i.e. without providing the amount or // script, while also verifying the bond output is unspent. However, it's diff --git a/client/core/bond.go b/client/core/bond.go index df695c558d..d1eb7580db 100644 --- a/client/core/bond.go +++ b/client/core/bond.go @@ -518,7 +518,7 @@ func (c *Core) refundExpiredBonds(ctx context.Context, acct *dexAccount, cfg *de var refundCoinStr string var refundVal uint64 var bondAlreadySpent bool - if bond.KeyIndex == math.MaxUint32 { // invalid/unknown key index fallback (v0 db.Bond, which was never released), also will skirt reserves :/ + if bond.KeyIndex == math.MaxUint32 { // invalid/unknown key index fallback (v0 db.Bond, which was never released, or unknown bond from server), also will skirt reserves :/ if len(bond.RefundTx) > 0 { refundCoinID, err := wallet.SendTransaction(bond.RefundTx) if err != nil { @@ -529,7 +529,7 @@ func (c *Core) refundExpiredBonds(ctx context.Context, acct *dexAccount, cfg *de } else { // else "Unknown bond reported by server", see result.ActiveBonds in authDEX bondAlreadySpent = true } - } else { // expected case -- TODO: remove the math.MaxUint32 sometime after 0.6 release + } else { // expected case -- TODO: remove the math.MaxUint32 sometime after bonds V1 priv, err := c.bondKeyIdx(bond.AssetID, bond.KeyIndex) if err != nil { c.log.Errorf("Failed to derive bond private key: %v", err) diff --git a/client/core/core.go b/client/core/core.go index 493d3ca08f..bd50b6715d 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -13,6 +13,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net" "net/url" "os" @@ -6824,6 +6825,99 @@ func (dc *dexConnection) updateReputation( } +// findBondKeyIdx will attempt to find the address index whose public key hashes +// to a specific hash. +func (c *Core) findBondKeyIdx(pkhEqualFn func(bondKey *secp256k1.PrivateKey) bool, assetID uint32) (idx uint32, err error) { + if !c.bondKeysReady() { + return 0, errors.New("bond key is not initialized") + } + nbki, err := c.db.NextBondKeyIndex(assetID) + if err != nil { + return 0, fmt.Errorf("unable to get next bond key index: %v", err) + } + maxIdx := nbki + 10_000 + for i := uint32(0); i < maxIdx; i++ { + bondKey, err := c.bondKeyIdx(assetID, i) + if err != nil { + return 0, fmt.Errorf("error getting bond key at idx %d: %v", i, err) + } + equal := pkhEqualFn(bondKey) + bondKey.Zero() + if equal { + return i, nil + } + } + return 0, fmt.Errorf("searched until idx %d but did not find a pubkey match", maxIdx) +} + +// findBond will attempt to find an unknown bond and add it to the live bonds +// slice and db for refunding later. Returns the bond strength if no error. +func (c *Core) findBond(dc *dexConnection, bond *msgjson.Bond) (strength, bondAssetID uint32) { + symb := dex.BipIDSymbol(bond.AssetID) + bondIDStr := coinIDString(bond.AssetID, bond.CoinID) + c.log.Warnf("Unknown bond reported by server: %v (%s)", bondIDStr, symb) + + wallet, err := c.connectedWallet(bond.AssetID) + if err != nil { + c.log.Errorf("%d -> %s wallet error: %w", bond.AssetID, unbip(bond.AssetID), err) + return 0, 0 + } + + // The server will only tell us about active bonds, so we only need + // search in the possible active timeframe before that. Server will tell + // us when the expiry is, so can subtract from that. Add a day out of + // caution. + bondExpiry := int64(dc.config().BondExpiry) + activeBondTimeframe := minBondLifetime(c.net, bondExpiry) - time.Second*time.Duration(bondExpiry) + time.Second*(60*60*24) // seconds * minutes * hours + + bondDetails, err := wallet.FindBond(c.ctx, bond.CoinID, time.Unix(int64(bond.Expiry), 0).Add(-activeBondTimeframe)) + if err != nil { + c.log.Errorf("Unable to find active bond reported by the server: %v", err) + return 0, 0 + } + + bondAsset, _ := dc.bondAsset(bond.AssetID) + if bondAsset == nil { + // Probably not possible since the dex told us about it. Keep + // going to refund it later. + c.log.Warnf("Dex does not support fidelity bonds in asset %s", symb) + strength = bond.Strength + } else { + strength = uint32(bondDetails.Amount / bondAsset.Amt) + } + + idx, err := c.findBondKeyIdx(bondDetails.CheckPrivKey, bond.AssetID) + if err != nil { + c.log.Warnf("Unable to find bond key index for unknown bond %s, will not be able to refund: %v", bondIDStr, err) + idx = math.MaxUint32 + } + + dbBond := &db.Bond{ + Version: bondDetails.Version, + AssetID: bondDetails.AssetID, + CoinID: bondDetails.CoinID, + Data: bondDetails.Data, + Amount: bondDetails.Amount, + LockTime: uint64(bondDetails.LockTime.Unix()), + KeyIndex: idx, + Strength: strength, + Confirmed: true, + } + + err = c.db.AddBond(dc.acct.host, dbBond) + if err != nil { + c.log.Errorf("Failed to store bond %s (%s) for dex %v: %w", + bondIDStr, unbip(bond.AssetID), dc.acct.host, err) + return 0, 0 + } + + dc.acct.authMtx.Lock() + dc.acct.bonds = append(dc.acct.bonds, dbBond) + dc.acct.authMtx.Unlock() + c.log.Infof("Restored unknown bond %s", bondIDStr) + return strength, bondDetails.AssetID +} + func (dc *dexConnection) maxScore() uint32 { if maxScore := dc.config().MaxScore; maxScore > 0 { return maxScore @@ -7067,14 +7161,33 @@ func (c *Core) authDEX(dc *dexConnection) error { localBondMap[bondKey(dbBond.AssetID, dbBond.CoinID)] = struct{}{} } + var unknownBondStrength uint32 + unknownBondAssetID := -1 for _, bond := range result.ActiveBonds { key := bondKey(bond.AssetID, bond.CoinID) if _, found := localBondMap[key]; found { continue } - symb := dex.BipIDSymbol(bond.AssetID) - bondIDStr := coinIDString(bond.AssetID, bond.CoinID) - c.log.Warnf("Unknown bond reported by server: %v (%s)", bondIDStr, symb) + // Server reported a bond we do not know about. + ubs, ubaID := c.findBond(dc, bond) + unknownBondStrength += ubs + if unknownBondAssetID != -1 && ubs != 0 && uint32(unknownBondAssetID) != ubaID { + c.log.Warnf("Found unknown bonds for different assets. %s and %s.", + unbip(uint32(unknownBondAssetID)), unbip(ubaID)) + } + if ubs != 0 { + unknownBondAssetID = int(ubaID) + } + } + + // If there were unknown bonds and tier is zero, this may be a restored + // client and so requires action by the user to set their target bond + // tier. Warn the user of this. + if unknownBondStrength > 0 && dc.acct.targetTier == 0 { + subject, details := c.formatDetails(TopicUnknownBondTierZero, unbip(uint32(unknownBondAssetID)), dc.acct.host) + c.notify(newUnknownBondTierZeroNote(subject, details)) + c.log.Warnf("Unknown bonds for asset %s found for dex %s while target tier is zero.", + unbip(uint32(unknownBondAssetID)), dc.acct.host) } // Associate the matches with known trades. diff --git a/client/core/core_test.go b/client/core/core_test.go index 0f6a6d8ba5..5b2e33319e 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -723,6 +723,8 @@ type TXCWallet struct { refundBondErr error makeBondTxErr error reserves atomic.Uint64 + findBond *asset.BondDetails + findBondErr error confirmRedemptionResult *asset.ConfirmRedemptionStatus confirmRedemptionErr error @@ -1109,6 +1111,10 @@ func (w *TXCWallet) RefundBond(ctx context.Context, ver uint16, coinID, script [ return w.refundBondCoin, w.refundBondErr } +func (w *TXCWallet) FindBond(ctx context.Context, coinID []byte, searchUntil time.Time) (bond *asset.BondDetails, err error) { + return w.findBond, w.findBondErr +} + func (w *TXCWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time.Time, privKey *secp256k1.PrivateKey, acctID []byte) (*asset.Bond, func(), error) { if w.makeBondTxErr != nil { return nil, nil, w.makeBondTxErr @@ -2141,6 +2147,8 @@ func TestPostBond(t *testing.T) { clearConn() tWallet.setConfs(tWallet.bondTxCoinID, 0, nil) + // Skip finding bonds. + tWallet.findBondErr = errors.New("purposeful error") _, err = tCore.PostBond(form) } @@ -10812,3 +10820,90 @@ func TestRotateBonds(t *testing.T) { t.Fatalf("Unmergeable bond was scheduled for merged") } } + +func TestFindBondKeyIdx(t *testing.T) { + rig := newTestRig() + defer rig.shutdown() + rig.core.Login(tPW) + + pkhEqualFnFn := func(find bool) func(bondKey *secp256k1.PrivateKey) bool { + return func(bondKey *secp256k1.PrivateKey) bool { + return find + } + } + tests := []struct { + name string + pkhEqualFn func(bondKey *secp256k1.PrivateKey) bool + wantErr bool + }{{ + name: "ok", + pkhEqualFn: pkhEqualFnFn(true), + }, { + name: "cant find", + pkhEqualFn: pkhEqualFnFn(false), + wantErr: true, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := rig.core.findBondKeyIdx(test.pkhEqualFn, 0) + if test.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestFindBond(t *testing.T) { + rig := newTestRig() + defer rig.shutdown() + dcrWallet, tDcrWallet := newTWallet(tUTXOAssetA.ID) + rig.core.wallets[tUTXOAssetA.ID] = dcrWallet + rig.core.Login(tPW) + + bd := &asset.BondDetails{ + Bond: &asset.Bond{ + Amount: tFee, + AssetID: tUTXOAssetA.ID, + }, + LockTime: time.Now(), + CheckPrivKey: func(bondKey *secp256k1.PrivateKey) bool { + return true + }, + } + msgBond := &msgjson.Bond{ + Version: 0, + AssetID: tUTXOAssetA.ID, + } + + tests := []struct { + name string + findBond *asset.BondDetails + findBondErr error + wantStr uint32 + }{{ + name: "ok", + findBond: bd, + wantStr: 1, + }, { + name: "find bond error", + findBondErr: errors.New("some error"), + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tDcrWallet.findBond = test.findBond + tDcrWallet.findBondErr = test.findBondErr + str, _ := rig.core.findBond(rig.dc, msgBond) + if str != test.wantStr { + t.Fatalf("wanted str %d but got %d", test.wantStr, str) + } + }) + } +} diff --git a/client/core/locale_ntfn.go b/client/core/locale_ntfn.go index b94b0fccc9..0fd54c8677 100644 --- a/client/core/locale_ntfn.go +++ b/client/core/locale_ntfn.go @@ -425,6 +425,13 @@ var originLocale = map[Topic]*translation{ subject: "Account registered", template: "New tier = %d", }, + // [bond asset, dex host] + TopicUnknownBondTierZero: { + subject: "Unknown bond found", + template: "Unknown %s bonds were found and added to active bonds " + + "but your target tier is zero for the dex at %s. Set your " + + "target tier in Settings to stay bonded with auto renewals.", + }, } var ptBR = map[Topic]*translation{ diff --git a/client/core/notification.go b/client/core/notification.go index 9bf96a80bb..c77ab4480a 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -20,6 +20,7 @@ const ( NoteTypeFeePayment = "feepayment" NoteTypeBondPost = "bondpost" NoteTypeBondRefund = "bondrefund" + NoteTypeUnknownBond = "unknownbond" NoteTypeSend = "send" NoteTypeOrder = "order" NoteTypeMatch = "match" @@ -723,3 +724,12 @@ func newReputationNote(host string, rep account.Reputation) *ReputationNote { Reputation: rep, } } + +const TopicUnknownBondTierZero = "UnknownBondTierZero" + +// newUnknownBondTierZeroNote is used when unknown bonds are reported by the +// server while at target tier zero. +func newUnknownBondTierZeroNote(subject, details string) *db.Notification { + note := db.NewNotification(NoteTypeUnknownBond, TopicUnknownBondTierZero, subject, details, db.WarningLevel) + return ¬e +} diff --git a/client/core/wallet.go b/client/core/wallet.go index 8e8f080143..cae7e7d70e 100644 --- a/client/core/wallet.go +++ b/client/core/wallet.go @@ -582,6 +582,16 @@ func (w *xcWallet) RefundBond(ctx context.Context, ver uint16, coinID, script [] return bonder.RefundBond(ctx, ver, coinID, script, amt, priv) } +// FindBond finds the bond with coinID and returns the values used to create it. +// The output should be unspent with the lockTime set to some time in the future. +func (w *xcWallet) FindBond(ctx context.Context, coinID []byte, searchUntil time.Time) (*asset.BondDetails, error) { + bonder, ok := w.Wallet.(asset.Bonder) + if !ok { + return nil, errors.New("wallet does not support making bond transactions") + } + return bonder.FindBond(ctx, coinID, searchUntil) +} + // SendTransaction broadcasts a raw transaction if the wallet is a Broadcaster. func (w *xcWallet) SendTransaction(tx []byte) ([]byte, error) { bonder, ok := w.Wallet.(asset.Broadcaster)