From b48a4c196ca4644d1bbfe5e722e00717cc766732 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Thu, 23 Nov 2023 10:11:59 +0900 Subject: [PATCH] multi: Add FindBond to Bonder. 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 | 134 +++++++++++++++++++++++ client/asset/btc/btc_test.go | 207 +++++++++++++++++++++++++++++++---- client/asset/btc/spv_test.go | 18 +-- client/asset/dcr/dcr.go | 105 ++++++++++++++++++ client/asset/dcr/dcr_test.go | 151 +++++++++++++++++++++++++ client/asset/interface.go | 13 +++ client/core/bond.go | 4 +- client/core/core.go | 120 +++++++++++++++++++- client/core/core_test.go | 95 ++++++++++++++++ client/core/wallet.go | 10 ++ 10 files changed, 823 insertions(+), 34 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 521a8f2385..5cc34c1aac 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -5026,6 +5026,140 @@ 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( + _ 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 { + 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 7ee690b320..81e9bab3dd 100644 --- a/client/asset/btc/spv_test.go +++ b/client/asset/btc/spv_test.go @@ -240,7 +240,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) @@ -306,7 +306,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, @@ -380,7 +380,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, @@ -403,7 +403,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) } @@ -411,7 +411,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") } @@ -427,7 +427,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) } @@ -853,7 +853,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 } @@ -895,11 +895,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 098a1eec92..46d8d0d651 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -4259,6 +4259,111 @@ 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 { + 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 5a6b7e46aa..69700d2439 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -4377,3 +4377,154 @@ func TestConfirmRedemption(t *testing.T) { } } } + +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 294cada58d..639054f6b5 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 eab1e09539..26d51c08e4 100644 --- a/client/core/bond.go +++ b/client/core/bond.go @@ -519,7 +519,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 { @@ -530,7 +530,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 8fa58a92af..378808e9fb 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" @@ -6758,6 +6759,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 @@ -7001,14 +7095,34 @@ 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 && uint32(unknownBondAssetID) != ubaID { + c.log.Warnf("Found unknown bonds for different assets. %s and %s.", + unbip(uint32(unknownBondAssetID)), unbip(ubaID)) + } + unknownBondAssetID = int(ubaID) + } + + // If there were unknown bonds, and target tier is zero, assume this was + // a restored account and set the tier according to the previous tier. + if unknownBondStrength > 0 { + dc.acct.authMtx.RLock() + if dc.acct.targetTier == 0 { + c.log.Infof("Setting target tier with host %s to %d with asset %s.", + dc.acct.host, unknownBondStrength, unbip(uint32(unknownBondAssetID))) + dc.acct.targetTier = uint64(unknownBondStrength) + dc.acct.bondAsset = uint32(unknownBondAssetID) + } + dc.acct.authMtx.RUnlock() } // Associate the matches with known trades. diff --git a/client/core/core_test.go b/client/core/core_test.go index 740933f311..7d68040f99 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -720,6 +720,8 @@ type TXCWallet struct { refundBondErr error makeBondTxErr error reserves atomic.Uint64 + findBond *asset.BondDetails + findBondErr error confirmRedemptionResult *asset.ConfirmRedemptionStatus confirmRedemptionErr error @@ -1106,6 +1108,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 @@ -2138,6 +2144,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) } @@ -10808,3 +10816,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/wallet.go b/client/core/wallet.go index a773615083..7e6fbace0c 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)