Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bonds: UI updates and import/export #2200

Merged
merged 8 commits into from
Mar 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion client/asset/dcr/dcr.go
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,13 @@ func bondsFeeBuffer(highFeeRate uint64) uint64 {
return parallelTracks * largeBondTxSize * highFeeRate
}

// BondsFeeBuffer suggests how much extra may be required for the transaction
// fees part of required bond reserves when bond rotation is enabled.
func (dcr *ExchangeWallet) BondsFeeBuffer() uint64 {
// 150% of the fee buffer portion of the reserves.
return 15 * bondsFeeBuffer(dcr.config().feeRateLimit) / 10
}

// RegisterUnspent should be called once for every configured DEX with existing
// unspent bond amounts, prior to login, which is when reserves for future bonds
// are then added given the actual account tier, target tier, and this combined
Expand Down Expand Up @@ -3739,7 +3746,7 @@ func (dcr *ExchangeWallet) makeBondRefundTxV0(txid *chainhash.Hash, vout uint32,
pk := priv.PubKey().SerializeCompressed()
pkh := stdaddr.Hash160(pk)
if !bytes.Equal(pkh, pkhPush) {
return nil, fmt.Errorf("incorrect private key to spend the bond output")
return nil, asset.ErrIncorrectBondKey
}

redeemMsgTx := wire.NewMsgTx()
Expand Down
12 changes: 11 additions & 1 deletion client/asset/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,15 @@ const (
// ErrNotEnoughConfirms is returned when a transaction is confirmed,
// but does not have enough confirmations to be trusted.
ErrNotEnoughConfirms = dex.ErrorKind("transaction does not have enough confirmations")
// ErrWalletTypeDisabled inidicates that a wallet type is no longer
// ErrWalletTypeDisabled indicates that a wallet type is no longer
// available.
ErrWalletTypeDisabled = dex.ErrorKind("wallet type has been disabled")
// ErrInsufficientBalance is returned when there is insufficient available
// balance for an operation, such as reserving funds for future bonds.
ErrInsufficientBalance = dex.ErrorKind("insufficient available balance")
// ErrIncorrectBondKey is returned when a provided private key is incorrect
// for a bond output.
ErrIncorrectBondKey = dex.ErrorKind("incorrect private key")

// InternalNodeLoggerName is the name for a logger that is used to fine
// tune log levels for only loggers using this name.
Expand Down Expand Up @@ -476,6 +479,13 @@ type Broadcaster interface {
type Bonder interface {
Broadcaster

// BondsFeeBuffer suggests how much extra may be required for the
// transaction fees part of bond reserves when bond rotation is enabled.
// This should return an amount larger than the minimum required by the
// asset's reserves system for fees, if non-zero, so that a reserves
// "deficit" does not appear right after the first bond is posted.
BondsFeeBuffer() uint64

// RegisterUnspent informs the wallet of a certain amount already locked in
// unspent bonds that will eventually be refunded with RefundBond. This
// should be used prior to ReserveBondFunds. This alone does not enable
Expand Down
113 changes: 99 additions & 14 deletions client/core/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"encoding/hex"
"errors"
"fmt"
"math"

"decred.org/dcrdex/client/comms"
"decred.org/dcrdex/client/db"
"decred.org/dcrdex/dex/encode"
"decred.org/dcrdex/server/account"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
Expand Down Expand Up @@ -138,17 +140,70 @@ func (c *Core) AccountImport(pw []byte, acct *Account, bonds []*db.Bond) error {
return newError(addressParseErr, "error parsing address: %w", err)
}

// Don't try to create and import an account for a DEX that we are already
// connected to.
c.connMtx.RLock()
_, connected := c.conns[host]
c.connMtx.RUnlock()
if connected {
return errors.New("already connected")
}
_, err = c.db.Account(host) // may just not be in the conns map
if err == nil {
return errors.New("account already exists")
// Don't try to create and import an account for a DEX that we already know,
// but try to import missing bonds.
if acctInfo, err := c.db.Account(host); err == nil {
// Before importing bonds, make sure this is the same DEX (by public
// key) and same account ID, otherwise the bonds do not apply. The user
// can still refund by manually broadcasting the backup refund tx.
if acct.DEXPubKey != hex.EncodeToString(acctInfo.DEXPubKey.SerializeCompressed()) {
return errors.New("known dex host has different public key")
}
keyB, err := crypter.Decrypt(acctInfo.EncKey())
if err != nil {
return err
}
defer encode.ClearBytes(keyB)
privKey := secp256k1.PrivKeyFromBytes(keyB)
defer privKey.Zero()
accountID := account.NewID(privKey.PubKey().SerializeCompressed())
if acct.AccountID != accountID.String() {
return errors.New("known dex account has different identity")
}

c.log.Infof("Found existing account for %s. Merging bonds...", host)
haveBond := func(bond *db.Bond) *db.Bond {
for _, knownBond := range acctInfo.Bonds {
if bytes.Equal(knownBond.UniqueID(), bond.UniqueID()) {
return knownBond
}
}
return nil
}
var newLiveBonds int
for _, bond := range bonds {
have := haveBond(bond)
if have != nil && have.KeyIndex != math.MaxUint32 {
continue // we have this proper (not placeholder) bond already
}
if err = c.db.AddBond(host, bond); err != nil { // add OR update
return fmt.Errorf("importing bond: %v", err)
}
if have == nil {
acctInfo.Bonds = append(acctInfo.Bonds, bond)
} else { // else this is the placeholder from Unknown active bond reported by server
*have = *bond // update element in acctInfo.Bonds slice
}
if !bond.Refunded {
newLiveBonds++
}
}
if newLiveBonds == 0 {
return nil
}
c.log.Infof("Imported %d new unspent bonds", newLiveBonds)
if dc, connected, _ := c.dex(host); connected {
c.disconnectDEX(dc)
// TODO: less heavy handed approach to append or update
// dc.acct.{bonds,pendingBonds,expiredBonds}, using server config...
Comment on lines +197 to +198
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the more sophisticated technique to roughly 1) add the bond to appropriate map, 2) update tier, and 3) call monitorBondConfs?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add or update. Probably other error prone stuff.

}
dc, err := c.connectDEX(acctInfo)
if err != nil {
return err
}
c.addDexConnection(dc)
c.initializeDEXConnections(crypter)
return nil
}

accountInfo := db.AccountInfo{
Expand All @@ -175,16 +230,41 @@ func (c *Core) AccountImport(pw []byte, acct *Account, bonds []*db.Bond) error {
return codedError(decodeErr, err)
}

accountInfo.LegacyFeePaid = acct.FeeProofSig != "" && acct.FeeProofStamp != 0

// Before we import the private key as LegacyEncKey, see if the account
// derives from the app seed. Somewhat inconsequential except for logging
// and use of the appropriate enc key field.
privKey, err := hex.DecodeString(acct.PrivKey)
if err != nil {
return codedError(decodeErr, err)
}
accountInfo.LegacyEncKey, err = crypter.Encrypt(privKey)
encKey, err := crypter.Encrypt(privKey)
if err != nil {
return codedError(encryptionErr, err)
}

accountInfo.LegacyFeePaid = acct.FeeProofSig != "" && acct.FeeProofStamp != 0
dcAcct := newDEXAccount(&accountInfo, false)
creds := c.creds()
const maxRecoveryIndex = 1000
for keyIndex := uint32(0); keyIndex < maxRecoveryIndex; keyIndex++ {
err := dcAcct.setupCryptoV2(creds, crypter, keyIndex)
if err != nil {
return newError(acctKeyErr, "setupCryptoV2 error: %w", err)
}
if bytes.Equal(privKey, dcAcct.privKey.Serialize()) {
c.log.Debugf("Account derives from current application seed, with account key index %d", keyIndex)
accountInfo.EncKeyV2 = encKey
// Any unspent bonds for this account will refund using KeyIndex.
break
}
}
if len(accountInfo.EncKeyV2) == 0 {
c.log.Warnf("Account with foreign key imported. " +
"Any imported bonds will be refunded to the previous wallet!")
accountInfo.LegacyEncKey = encKey
// Any unspent bonds for this account will refund using the backup tx.
}
dcAcct.privKey.Zero()

err = c.db.CreateAccount(&accountInfo)
if err != nil {
Expand All @@ -207,6 +287,11 @@ func (c *Core) AccountImport(pw []byte, acct *Account, bonds []*db.Bond) error {
}
}

dc, err := c.connectDEX(&accountInfo)
if err != nil {
return err
}
c.addDexConnection(dc)
c.initializeDEXConnections(crypter)
return nil
}
Expand Down
60 changes: 42 additions & 18 deletions client/core/bond.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,11 +345,22 @@ func (c *Core) rotateBonds(ctx context.Context) {
refundCoin, err := wallet.RefundBond(ctx, bond.Version, bond.CoinID, bond.Data, bond.Amount, priv)
priv.Zero()
bondAlreadySpent = errors.Is(err, asset.CoinNotFoundError) // or never mined!
if err != nil && !bondAlreadySpent {
c.log.Errorf("Failed to generate bond refund tx: %v", err)
continue
if err != nil {
if errors.Is(err, asset.ErrIncorrectBondKey) { // imported account and app seed is different
c.log.Warnf("Private key to spend bond %v is not available. Broadcasting backup refund tx.", bondIDStr)
refundCoinID, err := wallet.SendTransaction(bond.RefundTx)
if err != nil {
c.log.Errorf("Failed to broadcast bond refund txn %x: %v", bond.RefundTx, err)
continue
}
refundCoinStr, _ = asset.DecodeCoinID(bond.AssetID, refundCoinID)
} else if !bondAlreadySpent {
c.log.Errorf("Failed to generate bond refund tx: %v", err)
continue
}
} else {
refundCoinStr, refundVal = refundCoin.String(), refundCoin.Value()
}
refundCoinStr, refundVal = refundCoin.String(), refundCoin.Value()
}
// RefundBond increases reserves when it spends the bond, adding to
// the wallet's balance (available or immature).
Expand Down Expand Up @@ -396,7 +407,9 @@ func (c *Core) rotateBonds(ctx context.Context) {

bondAsset := bondAssets[bondAssetID]
if bondAsset == nil {
c.log.Warnf("Bond asset %d not supported by DEX %v", bondAssetID, dc.acct.host)
if targetTier > 0 {
c.log.Warnf("Bond asset %d not supported by DEX %v", bondAssetID, dc.acct.host)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this only happen if the dex stopped supporting a bond asset they previously used to support?

}
continue
}

Expand Down Expand Up @@ -935,6 +948,22 @@ func (c *Core) UpdateBondOptions(form *BondOptionsForm) error {
return err
}

// BondsFeeBuffer suggests how much extra may be required for the transaction
// fees part of bond reserves when bond rotation is enabled. This may be used to
// inform the consumer how much extra (beyond double the bond amount) is
// required to facilitate uninterrupted maintenance of a target trading tier.
func (c *Core) BondsFeeBuffer(assetID uint32) (uint64, error) {
wallet, err := c.connectedWallet(assetID)
if err != nil {
return 0, err
}
bonder, ok := wallet.Wallet.(asset.Bonder)
if !ok {
return 0, errors.New("wallet does not support bonds")
}
return bonder.BondsFeeBuffer(), nil
}

// PostBond begins the process of posting a new bond for a new or existing DEX
// account. On return, the bond transaction will have been broadcast, and when
// the required number of confirmations is reached, Core will submit the bond
Expand Down Expand Up @@ -973,11 +1002,6 @@ func (c *Core) PostBond(form *PostBondForm) (*PostBondResult, error) {
if _, ok := wallet.Wallet.(asset.Bonder); !ok { // will fail in MakeBondTx, but assert early
return nil, fmt.Errorf("wallet %v is not an asset.Bonder", bondAssetSymbol)
}
_, err = wallet.refreshUnlock()
if err != nil {
// TODO: Unlock with form.AppPass?
return nil, fmt.Errorf("bond asset wallet %v is locked", unbip(bondAssetID))
}
if !wallet.synchronized() { // otherwise we might double spend if the wallet keys were used elsewhere
return nil, fmt.Errorf("wallet %v is not synchronized", unbip(bondAssetID))
}
Expand All @@ -996,6 +1020,14 @@ func (c *Core) PostBond(form *PostBondForm) (*PostBondResult, error) {
return nil, newError(addressParseErr, "error parsing address: %v", err)
}

// Get ready to generate the bond txn.
if !wallet.unlocked() {
err = wallet.Unlock(crypter)
if err != nil {
return nil, newError(walletAuthErr, "failed to unlock %s wallet: %v", unbip(wallet.AssetID), err)
}
}

var success, acctExists bool

// When creating an account or registering a view-only account, the default
Expand Down Expand Up @@ -1129,14 +1161,6 @@ func (c *Core) PostBond(form *PostBondForm) (*PostBondResult, error) {
dc.acct.authMtx.Unlock()
}

// Get ready to generate the bond txn.
if !wallet.unlocked() {
err = wallet.Unlock(crypter)
if err != nil {
return nil, newError(walletAuthErr, "failed to unlock %s wallet: %v", unbip(wallet.AssetID), err)
}
}

// Make a bond transaction for the account ID generated from our public key.
bondCoin, err := c.makeAndPostBond(dc, acctExists, wallet, form.Bond, lockTime, bondAsset)
if err != nil {
Expand Down
3 changes: 2 additions & 1 deletion client/db/bolt/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,8 @@ func (db *BoltDB) storeBond(bondBkt *bbolt.Bucket, bond *db.Bond) error {
return nil
}

// AddBond saves a new Bond for an existing DEX account.
// AddBond saves a new Bond or updates an existing bond for an existing DEX
// account.
func (db *BoltDB) AddBond(host string, bond *db.Bond) error {
acctKey := []byte(host)
return db.acctsUpdate(func(accts *bbolt.Bucket) error {
Expand Down
2 changes: 1 addition & 1 deletion client/db/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type DB interface {
// the same Host as the parameter. If no account exists with this host,
// an error is returned.
UpdateAccountInfo(ai *AccountInfo) error
// AddBond saves a new Bond for a DEX.
// AddBond saves a new Bond or updates an existing bond for a DEX.
AddBond(host string, bond *Bond) error
// NextBondKeyIndex returns the next bond key index and increments the
// stored value so that subsequent calls will always return a higher index.
Expand Down
29 changes: 15 additions & 14 deletions client/db/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,22 @@ func BondUID(assetID uint32, bondCoinID []byte) []byte {
return hashKey(append(uint32Bytes(assetID), bondCoinID...))
}

// Bond is stored in a sub-bucket of an account bucket.
// Bond is stored in a sub-bucket of an account bucket. The dex.Bytes type is
// used for certain fields so that the data marshals to/from hexadecimal.
type Bond struct {
Version uint16
AssetID uint32
CoinID []byte
UnsignedTx []byte
SignedTx []byte // can be obtained from msgjson.Bond.CoinID
Data []byte // e.g. redeem script
Amount uint64
LockTime uint64
KeyIndex uint32 // child key index for HD path: m / hdKeyPurposeBonds / assetID' / bondIndex
RefundTx []byte // pays to wallet that created it - only a backup for emergency!

Confirmed bool // if reached required confs according to server, not in serialization
Refunded bool // not in serialization
Version uint16 `json:"ver"`
AssetID uint32 `json:"asset"`
CoinID dex.Bytes `json:"coinID"`
UnsignedTx dex.Bytes `json:"utx"`
SignedTx dex.Bytes `json:"stx"` // can be obtained from msgjson.Bond.CoinID
Data dex.Bytes `json:"data"` // e.g. redeem script
Amount uint64 `json:"amt"`
LockTime uint64 `json:"lockTime"`
KeyIndex uint32 `json:"keyIndex"` // child key index for HD path: m / hdKeyPurposeBonds / assetID' / bondIndex
RefundTx dex.Bytes `json:"refundTx"` // pays to wallet that created it - only a backup for emergency!

Confirmed bool `json:"confirmed"` // if reached required confs according to server, not in serialization
Refunded bool `json:"refunded"` // not in serialization
}

// UniqueID computes the bond's unique ID for keying purposes.
Expand Down
Loading