From b0f9c11586f6aed13b94e720c7ac40eef4ef2c66 Mon Sep 17 00:00:00 2001 From: Jonathan Chappelow Date: Fri, 2 Jul 2021 17:51:32 -0500 Subject: [PATCH 1/5] client/db: storing, confirming, and refunding bonds --- client/db/bolt/db.go | 310 +++++++++++++++++++++++++++++--------- client/db/bolt/db_test.go | 14 +- client/db/interface.go | 18 ++- client/db/test/dbtest.go | 16 +- client/db/types.go | 136 ++++++++++++++--- dex/encode/encode.go | 6 +- dex/encode/encode_test.go | 4 +- 7 files changed, 390 insertions(+), 114 deletions(-) diff --git a/client/db/bolt/db.go b/client/db/bolt/db.go index e161b44107..fac4c63163 100644 --- a/client/db/bolt/db.go +++ b/client/db/bolt/db.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "decred.org/dcrdex/client/db" dexdb "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/config" @@ -52,8 +53,11 @@ var ( // Bolt works on []byte keys and values. These are some commonly used key and // value encodings. var ( + // bucket keys appBucket = []byte("appBucket") accountsBucket = []byte("accounts") + bondIndexesBucket = []byte("bondIndexes") + bondsSubBucket = []byte("bonds") // sub bucket of accounts disabledAccountsBucket = []byte("disabledAccounts") activeOrdersBucket = []byte("activeOrders") archivedOrdersBucket = []byte("orders") @@ -62,54 +66,66 @@ var ( botProgramsBucket = []byte("botPrograms") walletsBucket = []byte("wallets") notesBucket = []byte("notes") - versionKey = []byte("version") - linkedKey = []byte("linked") - feeProofKey = []byte("feecoin") - statusKey = []byte("status") - baseKey = []byte("base") - quoteKey = []byte("quote") - orderKey = []byte("order") - matchKey = []byte("match") - orderIDKey = []byte("orderID") - matchIDKey = []byte("matchID") - proofKey = []byte("proof") - activeKey = []byte("active") - dexKey = []byte("dex") - updateTimeKey = []byte("utime") - accountKey = []byte("account") - balanceKey = []byte("balance") - walletKey = []byte("wallet") - changeKey = []byte("change") - noteKey = []byte("note") - stampKey = []byte("stamp") - severityKey = []byte("severity") - ackKey = []byte("ack") - swapFeesKey = []byte("swapFees") - maxFeeRateKey = []byte("maxFeeRate") - redeemMaxFeeRateKey = []byte("redeemMaxFeeRate") - redemptionFeesKey = []byte("redeemFees") - accelerationsKey = []byte("accelerations") - typeKey = []byte("type") credentialsBucket = []byte("credentials") - seedGenTimeKey = []byte("seedGenTime") - encSeedKey = []byte("encSeed") - encInnerKeyKey = []byte("encInnerKey") - innerKeyParamsKey = []byte("innerKeyParams") - outerKeyParamsKey = []byte("outerKeyParams") - legacyKeyParamsKey = []byte("keyParams") - epochDurKey = []byte("epochDur") - fromVersionKey = []byte("fromVersion") - toVersionKey = []byte("toVersion") - fromSwapConfKey = []byte("fromSwapConf") - toSwapConfKey = []byte("toSwapConf") - optionsKey = []byte("options") - redemptionReservesKey = []byte("redemptionReservesKey") - refundReservesKey = []byte("refundReservesKey") - byteTrue = encode.ByteTrue - backupDir = "backup" - disabledRateSourceKey = []byte("disabledRateSources") - walletDisabledKey = []byte("walletDisabled") - programKey = []byte("program") + + // value keys + versionKey = []byte("version") + linkedKey = []byte("linked") + feeProofKey = []byte("feecoin") + statusKey = []byte("status") + baseKey = []byte("base") + quoteKey = []byte("quote") + orderKey = []byte("order") + matchKey = []byte("match") + orderIDKey = []byte("orderID") + matchIDKey = []byte("matchID") + proofKey = []byte("proof") + activeKey = []byte("active") + bondKey = []byte("bond") + confirmedKey = []byte("confirmed") + refundedKey = []byte("refunded") + lockTimeKey = []byte("lockTime") + dexKey = []byte("dex") + updateTimeKey = []byte("utime") + accountKey = []byte("account") + balanceKey = []byte("balance") + walletKey = []byte("wallet") + changeKey = []byte("change") + noteKey = []byte("note") + stampKey = []byte("stamp") + severityKey = []byte("severity") + ackKey = []byte("ack") + swapFeesKey = []byte("swapFees") + maxFeeRateKey = []byte("maxFeeRate") + redeemMaxFeeRateKey = []byte("redeemMaxFeeRate") + redemptionFeesKey = []byte("redeemFees") + accelerationsKey = []byte("accelerations") + typeKey = []byte("type") + seedGenTimeKey = []byte("seedGenTime") + encSeedKey = []byte("encSeed") + encInnerKeyKey = []byte("encInnerKey") + innerKeyParamsKey = []byte("innerKeyParams") + outerKeyParamsKey = []byte("outerKeyParams") + legacyKeyParamsKey = []byte("keyParams") + epochDurKey = []byte("epochDur") + fromVersionKey = []byte("fromVersion") + toVersionKey = []byte("toVersion") + fromSwapConfKey = []byte("fromSwapConf") + toSwapConfKey = []byte("toSwapConf") + optionsKey = []byte("options") + redemptionReservesKey = []byte("redemptionReservesKey") + refundReservesKey = []byte("refundReservesKey") + disabledRateSourceKey = []byte("disabledRateSources") + walletDisabledKey = []byte("walletDisabled") + programKey = []byte("program") + + // values + byteTrue = encode.ByteTrue + byteFalse = encode.ByteFalse + byteEpoch = uint16Bytes(uint16(order.OrderStatusEpoch)) + byteBooked = uint16Bytes(uint16(order.OrderStatusBooked)) + + backupDir = "backup" ) // BoltDB is a bbolt-based database backend for a DEX client. BoltDB satisfies @@ -139,7 +155,7 @@ func NewDB(dbPath string, logger dex.Logger) (dexdb.DB, error) { } if err = bdb.makeTopLevelBuckets([][]byte{ - appBucket, accountsBucket, disabledAccountsBucket, + appBucket, accountsBucket, bondIndexesBucket, disabledAccountsBucket, activeOrdersBucket, archivedOrdersBucket, activeMatchesBucket, archivedMatchesBucket, walletsBucket, notesBucket, credentialsBucket, @@ -456,27 +472,57 @@ func (db *BoltDB) ListAccounts() ([]string, error) { }) } +func loadAccountInfo(acct *bbolt.Bucket, log dex.Logger) (*db.AccountInfo, error) { + acctB := getCopy(acct, accountKey) + if acctB == nil { + return nil, fmt.Errorf("empty account") + } + acctInfo, err := dexdb.DecodeAccountInfo(acctB) + if err != nil { + return nil, err + } + acctInfo.LegacyFeePaid = len(acct.Get(feeProofKey)) > 0 + + bondsBkt := acct.Bucket(bondsSubBucket) + if bondsBkt == nil { + return acctInfo, nil // no bonds, OK for legacy account + } + + c := bondsBkt.Cursor() + for bondUID, _ := c.First(); bondUID != nil; bondUID, _ = c.Next() { + bond := bondsBkt.Bucket(bondUID) + if acct == nil { + return nil, fmt.Errorf("bond sub-bucket %x not a nested bucket", bondUID) + } + dbBond, err := dexdb.DecodeBond(getCopy(bond, bondKey)) + if err != nil { + log.Errorf("Invalid bond data encoding: %v", err) + continue + } + dbBond.Confirmed = bEqual(bond.Get(confirmedKey), byteTrue) + dbBond.Refunded = bEqual(bond.Get(refundedKey), byteTrue) + acctInfo.Bonds = append(acctInfo.Bonds, dbBond) + } + + return acctInfo, nil +} + // Accounts returns a list of DEX Accounts. The DB is designed to have a single -// account per DEX, so the account itself is identified by the DEX URL. +// account per DEX, so the account itself is identified by the DEX host. TODO: +// allow bonds filter based on lockTime. func (db *BoltDB) Accounts() ([]*dexdb.AccountInfo, error) { var accounts []*dexdb.AccountInfo return accounts, db.acctsView(func(accts *bbolt.Bucket) error { c := accts.Cursor() - // key, _ := c.First() for acctKey, _ := c.First(); acctKey != nil; acctKey, _ = c.Next() { acct := accts.Bucket(acctKey) if acct == nil { return fmt.Errorf("account bucket %s value not a nested bucket", string(acctKey)) } - acctB := getCopy(acct, accountKey) - if acctB == nil { - return fmt.Errorf("empty account found for %s", string(acctKey)) - } - acctInfo, err := dexdb.DecodeAccountInfo(acctB) + acctInfo, err := loadAccountInfo(acct, db.log) if err != nil { return err } - acctInfo.Paid = len(acct.Get(feeProofKey)) > 0 accounts = append(accounts, acctInfo) } return nil @@ -487,23 +533,13 @@ func (db *BoltDB) Accounts() ([]*dexdb.AccountInfo, error) { func (db *BoltDB) Account(url string) (*dexdb.AccountInfo, error) { var acctInfo *dexdb.AccountInfo acctKey := []byte(url) - return acctInfo, db.acctsView(func(accts *bbolt.Bucket) error { + return acctInfo, db.acctsView(func(accts *bbolt.Bucket) (err error) { acct := accts.Bucket(acctKey) if acct == nil { - return fmt.Errorf("account not found for %s", url) - } - acctB := getCopy(acct, accountKey) - if acctB == nil { - return fmt.Errorf("empty account found for %s", url) - } - var err error - acctInfo, err = dexdb.DecodeAccountInfo(acctB) - if err != nil { - return err + return dexdb.ErrAcctNotFound } - acctInfo.Paid = len(acct.Get(feeProofKey)) > 0 - - return nil + acctInfo, err = loadAccountInfo(acct, db.log) + return }) } @@ -529,14 +565,52 @@ func (db *BoltDB) CreateAccount(ai *dexdb.AccountInfo) error { if err != nil { return fmt.Errorf("accountKey put error: %w", err) } - err = acct.Put(activeKey, byteTrue) + err = acct.Put(activeKey, byteTrue) // huh? if err != nil { return fmt.Errorf("activeKey put error: %w", err) } + + bonds, err := acct.CreateBucket(bondsSubBucket) + if err != nil { + return fmt.Errorf("unable to create bonds sub-bucket for account for %s: %w", ai.Host, err) + } + + for _, bond := range ai.Bonds { + bondUID := bond.UniqueID() + bondBkt, err := bonds.CreateBucketIfNotExists(bondUID) + if err != nil { + return fmt.Errorf("failed to create bond %x bucket: %w", bondUID, err) + } + + err = db.storeBond(bondBkt, bond) + if err != nil { + return err + } + } + return nil }) } +// NextBondKeyIndex returns the next bond key index and increments the stored +// value so that subsequent calls will always return a higher index. +func (db *BoltDB) NextBondKeyIndex(assetID uint32) (uint32, error) { + var bondIndex uint32 + return bondIndex, db.Update(func(tx *bbolt.Tx) error { + bkt := tx.Bucket(bondIndexesBucket) + if bkt == nil { + return errors.New("no bond indexes bucket") + } + + thisBondIdxKey := uint32Bytes(assetID) + bondIndexB := bkt.Get(thisBondIdxKey) + if len(bondIndexB) != 0 { + bondIndex = intCoder.Uint32(bondIndexB) + } + return bkt.Put(thisBondIdxKey, uint32Bytes(bondIndex+1)) + }) +} + // UpdateAccountInfo updates the account info for an existing account with // the same Host as the parameter. If no account exists with this host, // an error is returned. @@ -625,8 +699,9 @@ func (db *BoltDB) AccountProof(url string) (*dexdb.AccountProof, error) { }) } -// AccountPaid marks the account as paid by setting the "fee proof". -func (db *BoltDB) AccountPaid(proof *dexdb.AccountProof) error { +// StoreAccountProof marks the account as paid with the legacy registration fee +// by setting the "fee proof". +func (db *BoltDB) StoreAccountProof(proof *dexdb.AccountProof) error { acctKey := []byte(proof.Host) return db.acctsUpdate(func(accts *bbolt.Bucket) error { acct := accts.Bucket(acctKey) @@ -657,6 +732,95 @@ func (db *BoltDB) disabledAcctsUpdate(f bucketFunc) error { return db.withBucket(disabledAccountsBucket, db.Update, f) } +func (db *BoltDB) storeBond(bondBkt *bbolt.Bucket, bond *db.Bond) error { + err := bondBkt.Put(bondKey, bond.Encode()) + if err != nil { + return fmt.Errorf("bondKey put error: %w", err) + } + + confirmed := encode.ByteFalse + if bond.Confirmed { + confirmed = encode.ByteTrue + } + err = bondBkt.Put(confirmedKey, confirmed) + if err != nil { + return fmt.Errorf("confirmedKey put error: %w", err) + } + + refunded := encode.ByteFalse + if bond.Refunded { + refunded = encode.ByteTrue + } + err = bondBkt.Put(refundedKey, refunded) + if err != nil { + return fmt.Errorf("refundedKey put error: %w", err) + } + + err = bondBkt.Put(lockTimeKey, uint64Bytes(bond.LockTime)) // also in bond encoding + if err != nil { + return fmt.Errorf("lockTimeKey put error: %w", err) + } + + return nil +} + +// AddBond saves a new 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 { + acct := accts.Bucket(acctKey) + if acct == nil { + return fmt.Errorf("account not found for %s", host) + } + + bonds, err := acct.CreateBucketIfNotExists(bondsSubBucket) + if err != nil { + return fmt.Errorf("unable to access bonds sub-bucket for account for %s: %w", host, err) + } + + bondUID := bond.UniqueID() + bondBkt, err := bonds.CreateBucketIfNotExists(bondUID) + if err != nil { + return fmt.Errorf("failed to create bond %x bucket: %w", bondUID, err) + } + + return db.storeBond(bondBkt, bond) + }) +} + +func (db *BoltDB) setBondFlag(host string, assetID uint32, bondCoinID []byte, flagKey []byte) error { + acctKey := []byte(host) + return db.acctsUpdate(func(accts *bbolt.Bucket) error { + acct := accts.Bucket(acctKey) + if acct == nil { + return fmt.Errorf("account not found for %s", host) + } + + bonds := acct.Bucket(bondsSubBucket) + if bonds == nil { + return fmt.Errorf("bonds sub-bucket not found for account for %s", host) + } + + bondUID := dexdb.BondUID(assetID, bondCoinID) + bondBkt := bonds.Bucket(bondUID) + if bondBkt == nil { + return fmt.Errorf("bond bucket does not exist: %x", bondUID) + } + + return bondBkt.Put(flagKey, byteTrue) + }) +} + +// ConfirmBond marks a DEX account bond as confirmed by the DEX. +func (db *BoltDB) ConfirmBond(host string, assetID uint32, bondCoinID []byte) error { + return db.setBondFlag(host, assetID, bondCoinID, confirmedKey) +} + +// BondRefunded marks a DEX account bond as refunded by the client wallet. +func (db *BoltDB) BondRefunded(host string, assetID uint32, bondCoinID []byte) error { + return db.setBondFlag(host, assetID, bondCoinID, refundedKey) +} + // UpdateOrder saves the order information in the database. Any existing order // info for the same order ID will be overwritten without indication. func (db *BoltDB) UpdateOrder(m *dexdb.MetaOrder) error { diff --git a/client/db/bolt/db_test.go b/client/db/bolt/db_test.go index a59536da59..ccf65c0267 100644 --- a/client/db/bolt/db_test.go +++ b/client/db/bolt/db_test.go @@ -279,16 +279,19 @@ func TestAccounts(t *testing.T) { // Test account proofs. zerothHost := accts[0].Host zerothAcct, _ := boltdb.Account(zerothHost) - if zerothAcct.Paid { + if zerothAcct.LegacyFeePaid { t.Fatalf("Account marked as paid before account proof set") } - boltdb.AccountPaid(&db.AccountProof{ + err = boltdb.StoreAccountProof(&db.AccountProof{ Host: zerothAcct.Host, Stamp: 123456789, Sig: []byte("some signature here"), }) + if err != nil { + t.Fatalf("AccountPaid error: %v", err) + } reAcct, _ := boltdb.Account(zerothHost) - if !reAcct.Paid { + if !reAcct.LegacyFeePaid { t.Fatalf("Account not marked as paid after account proof set") } } @@ -341,11 +344,14 @@ func TestAccountProof(t *testing.T) { t.Fatalf("Unexpected CreateAccount error: %v", err) } - boltdb.AccountPaid(&db.AccountProof{ + err = boltdb.StoreAccountProof(&db.AccountProof{ Host: acct.Host, Stamp: 123456789, Sig: []byte("some signature here"), }) + if err != nil { + t.Fatalf("AccountPaid error: %v", err) + } accountProof, err := boltdb.AccountProof(host) if err != nil { diff --git a/client/db/interface.go b/client/db/interface.go index 0c8c4a9b27..d494b91840 100644 --- a/client/db/interface.go +++ b/client/db/interface.go @@ -26,7 +26,7 @@ type DB interface { walletUpdates map[uint32][]byte, acctUpdates map[string][]byte, err error) // ListAccounts returns a list of DEX URLs. The DB is designed to have a // single account per DEX, so the account is uniquely identified by the DEX - // URL. + // host. ListAccounts() ([]string, error) // Accounts retrieves all accounts. Accounts() ([]*AccountInfo, error) @@ -38,12 +38,22 @@ 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(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. + NextBondKeyIndex(assetID uint32) (uint32, error) + // ConfirmBond records that a bond has been accepted by a DEX. + ConfirmBond(host string, assetID uint32, bondCoinID []byte) error + // BondRefunded records that a bond has been refunded. + BondRefunded(host string, assetID uint32, bondCoinID []byte) error // DisableAccount sets the AccountInfo disabled status to true. DisableAccount(host string) error - // AccountProof retrieves the AccountPoof value specified by url. + // AccountProof retrieves the AccountPoof value specified by url. DEPRECATED AccountProof(host string) (*AccountProof, error) - // AccountPaid marks the account as paid. - AccountPaid(proof *AccountProof) error + // StoreAccountProof stores an AccountProof, marking the account as paid + // with the legacy registration fee. DEPRECATED + StoreAccountProof(proof *AccountProof) error // UpdateOrder saves the order information in the database. Any existing // order info will be overwritten without indication. UpdateOrder(m *MetaOrder) error diff --git a/client/db/test/dbtest.go b/client/db/test/dbtest.go index 20f8d2538a..e71623461b 100644 --- a/client/db/test/dbtest.go +++ b/client/db/test/dbtest.go @@ -42,11 +42,11 @@ func RandomAccountInfo() *db.AccountInfo { return &db.AccountInfo{ Host: ordertest.RandomAddress(), // LegacyEncKey: randBytes(32), - EncKeyV2: randBytes(32), - DEXPubKey: randomPubKey(), - FeeAssetID: uint32(rand.Intn(64)), - FeeCoin: randBytes(32), - Cert: randBytes(100), + EncKeyV2: randBytes(32), + DEXPubKey: randomPubKey(), + LegacyFeeAssetID: uint32(rand.Intn(64)), + LegacyFeeCoin: randBytes(32), + Cert: randBytes(100), } } @@ -167,6 +167,7 @@ func RandomNotification(maxTime uint64) *db.Notification { } type testKiller interface { + Helper() Fatalf(string, ...interface{}) } @@ -256,6 +257,7 @@ func MustCompareMatchProof(t testKiller, m1, m2 *db.MatchProof) { // MustCompareAccountInfo ensures the two AccountInfo are identical, calling the // Fatalf method of the testKiller if not. func MustCompareAccountInfo(t testKiller, a1, a2 *db.AccountInfo) { + t.Helper() if a1.Host != a2.Host { t.Fatalf("Host mismatch. %s != %s", a1.Host, a2.Host) } @@ -269,8 +271,8 @@ func MustCompareAccountInfo(t testKiller, a1, a2 *db.AccountInfo) { t.Fatalf("EncKey mismatch. %x != %x", a1.DEXPubKey.SerializeCompressed(), a2.DEXPubKey.SerializeCompressed()) } - if !bytes.Equal(a1.FeeCoin, a2.FeeCoin) { - t.Fatalf("EncKey mismatch. %x != %x", a1.FeeCoin, a2.FeeCoin) + if !bytes.Equal(a1.LegacyFeeCoin, a2.LegacyFeeCoin) { + t.Fatalf("EncKey mismatch. %x != %x", a1.LegacyFeeCoin, a2.LegacyFeeCoin) } } diff --git a/client/db/types.go b/client/db/types.go index 2412d24542..9a45421bad 100644 --- a/client/db/types.go +++ b/client/db/types.go @@ -40,8 +40,11 @@ const ( ErrorLevel ) -const ErrNoCredentials = dex.ErrorKind("no credentials have been stored") -const ErrNoSeedGenTime = dex.ErrorKind("seed generation time has not been stored") +const ( + ErrNoCredentials = dex.ErrorKind("no credentials have been stored") + ErrAcctNotFound = dex.ErrorKind("account not found") + ErrNoSeedGenTime = dex.ErrorKind("seed generation time has not been stored") +) // String satisfies fmt.Stringer for Severity. func (s Severity) String() string { @@ -79,6 +82,84 @@ type PrimaryCredentials struct { OuterKeyParams []byte } +// BondUID generates a unique identifier from a bond's asset ID and coin ID. +func BondUID(assetID uint32, bondCoinID []byte) []byte { + return hashKey(append(uint32Bytes(assetID), bondCoinID...)) +} + +// Bond is stored in a sub-bucket of an account bucket. +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 + PrivKey []byte // private key to which the bond script pays, TODO: encrypt + 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 +} + +// UniqueID computes the bond's unique ID for keying purposes. +func (b *Bond) UniqueID() []byte { + return BondUID(b.AssetID, b.CoinID) +} + +// Encode serialized the Bond. Confirmed and Refund are not included. +func (b *Bond) Encode() []byte { + return versionedBytes(0). + AddData(uint16Bytes(b.Version)). + AddData(uint32Bytes(b.AssetID)). + AddData(b.CoinID). + AddData(b.UnsignedTx). + AddData(b.SignedTx). + AddData(b.Data). + AddData(encode.Uint64Bytes(b.Amount)). + AddData(encode.Uint64Bytes(b.LockTime)). + AddData(b.PrivKey). + AddData(b.RefundTx) + // Confirmed and Refunded are not part of the encoding. +} + +// DecodeBond decodes the versioned blob into a *Bond. +func DecodeBond(b []byte) (*Bond, error) { + ver, pushes, err := encode.DecodeBlob(b) + if err != nil { + return nil, err + } + switch ver { + case 0: + return decodeBond_v0(pushes) + } + return nil, fmt.Errorf("unknown Bond version %d", ver) +} + +func decodeBond_v0(pushes [][]byte) (*Bond, error) { + if len(pushes) != 10 { + return nil, fmt.Errorf("decodeBond_v0: expected 10 data pushes, got %d", len(pushes)) + } + ver, assetIDB, coinID := pushes[0], pushes[1], pushes[2] + utx, stx := pushes[3], pushes[4] + data, amtB, lockTimeB := pushes[5], pushes[6], pushes[7] + privKey, refundTx := pushes[8], pushes[9] + return &Bond{ + Version: intCoder.Uint16(ver), + AssetID: intCoder.Uint32(assetIDB), + CoinID: coinID, + UnsignedTx: utx, + SignedTx: stx, + Data: data, + Amount: intCoder.Uint64(amtB), + LockTime: intCoder.Uint64(lockTimeB), + PrivKey: privKey, + RefundTx: refundTx, + }, nil +} + // AccountInfo is information about an account on a Decred DEX. The database // is designed for one account per server. type AccountInfo struct { @@ -95,13 +176,20 @@ type AccountInfo struct { // automatically. LegacyEncKey []byte - FeeAssetID uint32 - FeeCoin []byte - // Paid is set on retrieval based on whether there is an AccountProof set. - Paid bool + Bonds []*Bond + + // DEPRECATED reg fee data. Bond txns are in a sub-bucket. + + // LegacyFeeCoin is the a legacy registration fee coin ID. + LegacyFeeCoin []byte + LegacyFeeAssetID uint32 + // LegacyFeePaid should be set on retrieval if there is an AccountProof set. + LegacyFeePaid bool // DEPRECATED } -// Encode the AccountInfo as bytes. +// Encode the AccountInfo as bytes. NOTE: remove deprecated fee fields and do a +// DB upgrade at some point. But how to deal with old accounts needing to store +// this data forever? func (ai *AccountInfo) Encode() []byte { return versionedBytes(2). AddData([]byte(ai.Host)). @@ -109,8 +197,8 @@ func (ai *AccountInfo) Encode() []byte { AddData(ai.DEXPubKey.SerializeCompressed()). AddData(ai.EncKeyV2). AddData(ai.LegacyEncKey). - AddData(encode.Uint32Bytes(ai.FeeAssetID)). - AddData(ai.FeeCoin) + AddData(encode.Uint32Bytes(ai.LegacyFeeAssetID)). + AddData(ai.LegacyFeeCoin) } // EncKey is the encrypted account private key. @@ -130,7 +218,7 @@ func DecodeAccountInfo(b []byte) (*AccountInfo, error) { } switch ver { case 0: - return decodeAccountInfo_v0(pushes) + return decodeAccountInfo_v0(pushes) // caller must decode account proof case 1: return decodeAccountInfo_v1(pushes) case 2: @@ -154,13 +242,14 @@ func decodeAccountInfo_v1(pushes [][]byte) (*AccountInfo, error) { return nil, err } return &AccountInfo{ - Host: string(hostB), - LegacyEncKey: legacyKeyB, - DEXPubKey: pk, - FeeAssetID: 42, // only option at this version - FeeCoin: coinB, - Cert: certB, - EncKeyV2: v2Key, + Host: string(hostB), + Cert: certB, + DEXPubKey: pk, + EncKeyV2: v2Key, + LegacyEncKey: legacyKeyB, + LegacyFeeAssetID: 42, // only option at this version + LegacyFeeCoin: coinB, + // LegacyFeePaid comes from AccountProof. }, nil } @@ -170,7 +259,7 @@ func decodeAccountInfo_v2(pushes [][]byte) (*AccountInfo, error) { } hostB, certB, dexPkB := pushes[0], pushes[1], pushes[2] // dex identity v2Key, legacyKeyB := pushes[3], pushes[4] // account identity - regAssetB, coinB := pushes[5], pushes[6] // reg fee data + regAssetB, coinB := pushes[5], pushes[6] // legacy reg fee data pk, err := secp256k1.ParsePubKey(dexPkB) if err != nil { return nil, err @@ -181,14 +270,16 @@ func decodeAccountInfo_v2(pushes [][]byte) (*AccountInfo, error) { DEXPubKey: pk, EncKeyV2: v2Key, LegacyEncKey: legacyKeyB, - FeeAssetID: intCoder.Uint32(regAssetB), - FeeCoin: coinB, + // Bonds decoded by DecodeBond from separate pushes. + LegacyFeeAssetID: intCoder.Uint32(regAssetB), + LegacyFeeCoin: coinB, // NOTE: no longer in current serialization. + // LegacyFeePaid comes from AccountProof. }, nil } -// Account proof is information necessary to prove that the DEX server accepted +// AccountProof is information necessary to prove that the DEX server accepted // the account's fee payment. The fee coin is not part of the proof, since it -// is already stored as part of the AccountInfo blob. +// is already stored as part of the AccountInfo blob. DEPRECATED. type AccountProof struct { Host string Stamp uint64 @@ -768,6 +859,7 @@ func versionedBytes(v byte) encode.BuildyBytes { var uint64Bytes = encode.Uint64Bytes var uint32Bytes = encode.Uint32Bytes +var uint16Bytes = encode.Uint16Bytes var intCoder = encode.IntCoder // AccountBackup represents a user account backup. diff --git a/dex/encode/encode.go b/dex/encode/encode.go index e204457499..3e2270fb8e 100644 --- a/dex/encode/encode.go +++ b/dex/encode/encode.go @@ -18,15 +18,17 @@ var ( // IntCoder is the DEX-wide integer byte-encoding order. IntCoder must be // BigEndian so that variable length data encodings work as intended. IntCoder = binary.BigEndian - // A byte-slice representation of boolean false. + // ByteFalse is a byte-slice representation of boolean false. ByteFalse = []byte{0} - // A byte-slice representation of boolean true. + // ByteTrue is a byte-slice representation of boolean true. ByteTrue = []byte{1} // MaxDataLen is the largest byte slice that can be stored when using // (BuildyBytes).AddData. MaxDataLen = 0x00fe_ffff // top two bytes in big endian stop at 254, signalling 32-bit len ) +const maxU16 = int(^uint16(0)) + // Uint64Bytes converts the uint16 to a length-2, big-endian encoded byte slice. func Uint16Bytes(i uint16) []byte { b := make([]byte, 2) diff --git a/dex/encode/encode_test.go b/dex/encode/encode_test.go index a791fdf2f0..6be78426b2 100644 --- a/dex/encode/encode_test.go +++ b/dex/encode/encode_test.go @@ -41,7 +41,7 @@ func TestBuildyBytes(t *testing.T) { for _, p := range tt.pushes { b = b.AddData(p) } - if !bEqual(b, tt.exp) { + if !bytes.Equal(b, tt.exp) { t.Fatalf("test %d failed", i) } } @@ -142,7 +142,7 @@ func TestDecodeBlob(t *testing.T) { } for j, push := range pushes { check := tt.exp[j] - if !bEqual(check, push) { + if !bytes.Equal(check, push) { t.Fatalf("push %d:%d incorrect. wanted %x, got %x", i, j, check, push) } } From 6ab3f0cf5b513ed700980794298d90fa01851c9f Mon Sep 17 00:00:00 2001 From: Jonathan Chappelow Date: Fri, 2 Jul 2021 17:59:12 -0500 Subject: [PATCH 2/5] client/core: postbond to register and add bond --- client/core/account.go | 87 ++-- client/core/account_test.go | 208 +++++----- client/core/bond.go | 775 ++++++++++++++++++++++++++++++++++++ client/core/core.go | 429 ++++++++++++++++---- client/core/core_test.go | 75 +++- client/core/errors.go | 3 + client/core/notification.go | 56 ++- client/core/types.go | 97 +++-- client/core/wallet.go | 30 ++ 9 files changed, 1492 insertions(+), 268 deletions(-) create mode 100644 client/core/bond.go diff --git a/client/core/account.go b/client/core/account.go index f55bc58ec2..ce0775550c 100644 --- a/client/core/account.go +++ b/client/core/account.go @@ -6,6 +6,7 @@ import ( "fmt" "decred.org/dcrdex/client/db" + "decred.org/dcrdex/server/account" "github.com/decred/dcrd/dcrec/secp256k1/v4" ) @@ -67,39 +68,37 @@ func (c *Core) AccountDisable(pw []byte, addr string) error { } // AccountExport is used to retrieve account by host for export. -func (c *Core) AccountExport(pw []byte, host string) (*Account, error) { +func (c *Core) AccountExport(pw []byte, host string) (*Account, []*db.Bond, error) { crypter, err := c.encryptionKey(pw) if err != nil { - return nil, codedError(passwordErr, err) + return nil, nil, codedError(passwordErr, err) } defer crypter.Close() host, err = addrHost(host) if err != nil { - return nil, newError(addressParseErr, "error parsing address: %w", err) + return nil, nil, newError(addressParseErr, "error parsing address: %w", err) } - // Get the dexConnection and the dex.Asset for each asset. - c.connMtx.RLock() - dc, found := c.conns[host] - c.connMtx.RUnlock() - if !found { - return nil, newError(unknownDEXErr, "DEX: %s", host) + // Load account info, including all bonds, from DB. + acctInf, err := c.db.Account(host) + if err != nil { + return nil, nil, newError(unknownDEXErr, "dex db load error: %w", err) } - // Unlock account if it is locked so that account id and privKey can be retrieved. - if err = dc.acct.unlock(crypter); err != nil { - return nil, codedError(acctKeyErr, err) + keyB, err := crypter.Decrypt(acctInf.EncKey()) + if err != nil { + return nil, nil, err } - dc.acct.keyMtx.RLock() - privKey := hex.EncodeToString(dc.acct.privKey.Serialize()) - dc.acct.keyMtx.RUnlock() + privKey := secp256k1.PrivKeyFromBytes(keyB) + pubKey := privKey.PubKey() + accountID := account.NewID(pubKey.SerializeCompressed()) - feeProofSig := "" + var feeProofSig string var feeProofStamp uint64 - if dc.acct.isPaid { + if acctInf.LegacyFeePaid { accountProof, err := c.db.AccountProof(host) if err != nil { - return nil, codedError(accountProofErr, err) + return nil, nil, codedError(accountProofErr, err) } feeProofSig = hex.EncodeToString(accountProof.Sig) feeProofStamp = accountProof.Stamp @@ -108,22 +107,22 @@ func (c *Core) AccountExport(pw []byte, host string) (*Account, error) { // Account ID is exported for informational purposes only, it is not used during import. acct := &Account{ Host: host, - AccountID: dc.acct.id.String(), + AccountID: accountID.String(), // PrivKey: Note that we don't differentiate between legacy and // hierarchical private keys here. On import, all keys are treated as // legacy keys. - PrivKey: privKey, - DEXPubKey: hex.EncodeToString(dc.acct.dexPubKey.SerializeCompressed()), - Cert: hex.EncodeToString(dc.acct.cert), - FeeCoin: hex.EncodeToString(dc.acct.feeCoin), + PrivKey: hex.EncodeToString(keyB), + DEXPubKey: hex.EncodeToString(acctInf.DEXPubKey.SerializeCompressed()), + Cert: hex.EncodeToString(acctInf.Cert), + FeeCoin: hex.EncodeToString(acctInf.LegacyFeeCoin), FeeProofSig: feeProofSig, FeeProofStamp: feeProofStamp, } - return acct, nil + return acct, acctInf.Bonds, nil } // AccountImport is used import an existing account into the db. -func (c *Core) AccountImport(pw []byte, acct Account) error { +func (c *Core) AccountImport(pw []byte, acct *Account, bonds []*db.Bond) error { crypter, err := c.encryptionKey(pw) if err != nil { return codedError(passwordErr, err) @@ -133,7 +132,24 @@ func (c *Core) AccountImport(pw []byte, acct Account) error { if err != nil { return newError(addressParseErr, "error parsing address: %w", err) } - accountInfo := db.AccountInfo{Host: host} + + // 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") + } + + accountInfo := db.AccountInfo{ + Host: host, + Bonds: bonds, + } DEXpubKey, err := hex.DecodeString(acct.DEXPubKey) if err != nil { @@ -149,7 +165,7 @@ func (c *Core) AccountImport(pw []byte, acct Account) error { return codedError(decodeErr, err) } - accountInfo.FeeCoin, err = hex.DecodeString(acct.FeeCoin) + accountInfo.LegacyFeeCoin, err = hex.DecodeString(acct.FeeCoin) if err != nil { return codedError(decodeErr, err) } @@ -163,25 +179,14 @@ func (c *Core) AccountImport(pw []byte, acct Account) error { return codedError(encryptionErr, err) } - accountInfo.Paid = acct.FeeProofSig != "" && acct.FeeProofStamp != 0 - - // Make a connection to the DEX. - if dc, connected := c.connectAccount(&accountInfo); !connected { - if dc != nil { - dc.connMaster.Disconnect() // stop reconnect loop - c.connMtx.Lock() - delete(c.conns, dc.acct.host) - c.connMtx.Unlock() - } - return newError(accountVerificationErr, "Account not verified for host: %s", host) - } + accountInfo.LegacyFeePaid = acct.FeeProofSig != "" && acct.FeeProofStamp != 0 err = c.db.CreateAccount(&accountInfo) if err != nil { return codedError(dbErr, err) } - if accountInfo.Paid { + if accountInfo.LegacyFeePaid { sig, err := hex.DecodeString(acct.FeeProofSig) if err != nil { return codedError(decodeErr, err) @@ -191,7 +196,7 @@ func (c *Core) AccountImport(pw []byte, acct Account) error { Stamp: acct.FeeProofStamp, Sig: sig, } - err = c.db.AccountPaid(&accountProof) + err = c.db.StoreAccountProof(&accountProof) if err != nil { return codedError(dbErr, err) } diff --git a/client/core/account_test.go b/client/core/account_test.go index 4be588a028..2d12e75a78 100644 --- a/client/core/account_test.go +++ b/client/core/account_test.go @@ -16,6 +16,7 @@ import ( "github.com/decred/dcrd/dcrec/secp256k1/v4" ) +/* TODO: rework TestAccountExport func TestAccountExport(t *testing.T) { rig := newTestRig() tCore := rig.core @@ -24,7 +25,7 @@ func TestAccountExport(t *testing.T) { setupRigAccountProof(host, rig) - accountResponse, err := tCore.AccountExport(tPW, host) + accountResponse, _ , err := tCore.AccountExport(tPW, host) if err != nil { t.Fatalf("account keys error: %v", err) } @@ -56,17 +57,19 @@ func TestAccountExport(t *testing.T) { t.Fatal("unexpected FeeProofStamp") } } +*/ // If account is not paid then AccountProof should contain unset values func TestAccountExportNoAccountProof(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core host := tCore.conns[tDexHost].acct.host tCore.conns[tDexHost].acct.isPaid = false setupRigAccountProof(host, rig) - accountResponse, err := tCore.AccountExport(tPW, host) + accountResponse, _ /*bonds*/, err := tCore.AccountExport(tPW, host) if err != nil { t.Fatalf("account keys error: %v", err) } @@ -186,8 +189,8 @@ func TestAccountDisable(t *testing.T) { func TestUpdateCert(t *testing.T) { rig := newTestRig() tCore := rig.core - rig.db.acct.Paid = true - rig.db.acct.FeeCoin = encode.RandomBytes(32) + rig.db.acct.LegacyFeePaid = true + rig.db.acct.LegacyFeeCoin = encode.RandomBytes(32) tests := []struct { name string @@ -300,8 +303,8 @@ func TestUpdateDEXHost(t *testing.T) { for _, test := range tests { rig := newTestRig() tCore := rig.core - rig.db.acct.Paid = true - rig.db.acct.FeeCoin = encode.RandomBytes(32) + rig.db.acct.LegacyFeePaid = true + rig.db.acct.LegacyFeeCoin = encode.RandomBytes(32) rig.db.acct.Host = tDexHost tCore.connMtx.Lock() @@ -353,10 +356,11 @@ func TestUpdateDEXHost(t *testing.T) { func TestAccountExportPasswordError(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core host := tCore.conns[tDexHost].acct.host rig.crypter.(*tCrypter).recryptErr = tErr - _, err := tCore.AccountExport(tPW, host) + _, _, err := tCore.AccountExport(tPW, host) if !errorHasCode(err, passwordErr) { t.Fatalf("expected password error, actual error: '%v'", err) } @@ -364,9 +368,10 @@ func TestAccountExportPasswordError(t *testing.T) { func TestAccountExportAddressError(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core host := ":bad:" - _, err := tCore.AccountExport(tPW, host) + _, _, err := tCore.AccountExport(tPW, host) if !errorHasCode(err, addressParseErr) { t.Fatalf("expected address parse error, actual error: '%v'", err) } @@ -374,13 +379,13 @@ func TestAccountExportAddressError(t *testing.T) { func TestAccountExportUnknownDEX(t *testing.T) { rig := newTestRig() + defer rig.shutdown() + rig.db.acct.Host = "different" + // Test the db Account look up failing. + rig.db.acctErr = errors.New("acct retrieve error") + defer func() { rig.db.acctErr = nil }() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - // Lose the dexConnection - tCore.connMtx.Lock() - delete(tCore.conns, tDexHost) - tCore.connMtx.Unlock() - _, err := tCore.AccountExport(tPW, host) + _, _, err := tCore.AccountExport(tPW, rig.db.acct.Host) // any valid host is fine if !errorHasCode(err, unknownDEXErr) { t.Fatalf("expected unknown DEX error, actual error: '%v'", err) } @@ -388,10 +393,11 @@ func TestAccountExportUnknownDEX(t *testing.T) { func TestAccountExportAccountKeyError(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core host := tCore.conns[tDexHost].acct.host rig.crypter.(*tCrypter).decryptErr = tErr - _, err := tCore.AccountExport(tPW, host) + _, _, err := tCore.AccountExport(tPW, host) if !errorHasCode(err, passwordErr) { t.Fatalf("expected password error, actual error: '%v'", err) } @@ -399,19 +405,20 @@ func TestAccountExportAccountKeyError(t *testing.T) { func TestAccountExportAccountProofError(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core host := tCore.conns[tDexHost].acct.host - tCore.conns[tDexHost].acct.isPaid = true + rig.db.acct.LegacyFeePaid = true rig.db.accountProofErr = tErr - _, err := tCore.AccountExport(tPW, host) + _, _, err := tCore.AccountExport(tPW, host) if !errorHasCode(err, accountProofErr) { t.Fatalf("expected account proof error, actual error: '%v'", err) } } -func buildTestAccount(host string) Account { +func buildTestAccount(host string) *Account { privKey, _ := secp256k1.GeneratePrivateKey() - return Account{ + return &Account{ Host: host, AccountID: account.NewID(privKey.PubKey().SerializeCompressed()).String(), // can be anything though PrivKey: hex.EncodeToString(privKey.Serialize()), @@ -423,6 +430,7 @@ func buildTestAccount(host string) Account { } } +/* TODO: rework AccountImport func TestAccountImport(t *testing.T) { rig := newTestRig() tCore := rig.core @@ -439,22 +447,22 @@ func TestAccountImport(t *testing.T) { if !rig.db.verifyCreateAccount { t.Fatalf("expected execution of db.CreateAccount") } - if rig.db.acct.Host != host { - t.Fatalf("unexpected accountInfo Host") + if rig.db.accountInfoPersisted.Host != host { + t.Fatalf("unexprected accountInfo Host") } DEXpubKey, _ := hex.DecodeString(account.DEXPubKey) - if !bytes.Equal(rig.db.acct.DEXPubKey.SerializeCompressed(), DEXpubKey) { + if !bytes.Equal(rig.db.accountInfoPersisted.DEXPubKey.SerializeCompressed(), DEXpubKey) { t.Fatal("unexpected DEXPubKey") } feeCoin, _ := hex.DecodeString(account.FeeCoin) - if !bytes.Equal(rig.db.acct.FeeCoin, feeCoin) { + if !bytes.Equal(rig.db.accountInfoPersisted.FeeCoin, feeCoin) { t.Fatal("unexpected FeeCoin") } cert, _ := hex.DecodeString(account.Cert) - if !bytes.Equal(rig.db.acct.Cert, cert) { + if !bytes.Equal(rig.db.accountInfoPersisted.Cert, cert) { t.Fatal("unexpected Cert") } - if !rig.db.acct.Paid { + if !rig.db.accountInfoPersisted.Paid { t.Fatal("unexpected Paid value") } if rig.db.accountProofPersisted.Host != host { @@ -468,15 +476,18 @@ func TestAccountImport(t *testing.T) { t.Fatal("unexpected FeeProofStamp") } } +*/ func TestAccountImportEmptyFeeProofSig(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) account.FeeProofSig = "" rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil /* bonds */) if err != nil { t.Fatalf("account import error: %v", err) } @@ -488,33 +499,35 @@ func TestAccountImportEmptyFeeProofSig(t *testing.T) { } } -func TestAccountImportEmptyFeeProofStamp(t *testing.T) { - rig := newTestRig() - tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) - account.FeeProofStamp = 0 - rig.queueConfig() - err := tCore.AccountImport(tPW, account) - if err != nil { - t.Fatalf("account import error: %v", err) - } - if rig.db.verifyAccountPaid { - t.Fatalf("not expecting execution of db.AccountPaid") - } - if !rig.db.verifyCreateAccount { - t.Fatalf("expected execution of db.CreateAccount") - } -} +// func TestAccountImportEmptyFeeProofStamp(t *testing.T) { +// rig := newTestRig() +// tCore := rig.core +// host := tCore.conns[tDexHost].acct.host +// account := buildTestAccount(host) +// account.FeeProofStamp = 0 +// rig.queueConfig() +// err := tCore.AccountImport(tPW, account) +// if err != nil { +// t.Fatalf("account import error: %v", err) +// } +// if rig.db.verifyAccountPaid { +// t.Fatalf("not expecting execution of db.AccountPaid") +// } +// if !rig.db.verifyCreateAccount { +// t.Fatalf("expected execution of db.CreateAccount") +// } +// } func TestAccountImportPasswordError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) rig.queueConfig() rig.crypter.(*tCrypter).recryptErr = tErr - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, passwordErr) { t.Fatalf("expected password error, actual error: '%v'", err) } @@ -522,11 +535,13 @@ func TestAccountImportPasswordError(t *testing.T) { func TestAccountImportAddressError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := ":bad:" - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(":bad:") rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, addressParseErr) { t.Fatalf("expected address parse error, actual error: '%v'", err) } @@ -534,12 +549,14 @@ func TestAccountImportAddressError(t *testing.T) { func TestAccountImportDecodePubKeyError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) account.DEXPubKey = "bad" rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, decodeErr) { t.Fatalf("expected decode error, actual error: '%v'", err) } @@ -547,12 +564,14 @@ func TestAccountImportDecodePubKeyError(t *testing.T) { func TestAccountImportParsePubKeyError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) account.DEXPubKey = hex.EncodeToString([]byte("bad")) rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, parseKeyErr) { t.Fatalf("expected parse key error, actual error: '%v'", err) } @@ -560,12 +579,14 @@ func TestAccountImportParsePubKeyError(t *testing.T) { func TestAccountImportDecodeCertError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) account.Cert = "bad" rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, decodeErr) { t.Fatalf("expected decode error, actual error: '%v'", err) } @@ -573,39 +594,29 @@ func TestAccountImportDecodeCertError(t *testing.T) { func TestAccountImportDecodeFeeCoinError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) account.FeeCoin = "bad" rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, decodeErr) { t.Fatalf("expected decode error, actual error: '%v'", err) } } -func TestAccountImportAccountVerificationError(t *testing.T) { - rig := newTestRig() - tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) - account.FeeProofSig = "" - account.FeeCoin = "" - rig.queueConfig() - err := tCore.AccountImport(tPW, account) - if !errorHasCode(err, accountVerificationErr) { - t.Fatalf("expected account verification error, actual error: '%v'", err) - } -} - func TestAccountImportDecodePrivKeyError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) account.PrivKey = "bad" rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, decodeErr) { t.Fatalf("expected decode error, actual error: '%v'", err) } @@ -613,12 +624,14 @@ func TestAccountImportDecodePrivKeyError(t *testing.T) { func TestAccountImportEncryptPrivKeyError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) rig.crypter.(*tCrypter).encryptErr = tErr rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, encryptionErr) { t.Fatalf("expected encryption error, actual error: '%v'", err) } @@ -626,12 +639,15 @@ func TestAccountImportEncryptPrivKeyError(t *testing.T) { func TestAccountImportDecodeFeeProofSigError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) account.FeeProofSig = "bad" + account.FeeProofStamp = 1232325 rig.queueConfig() - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, decodeErr) { t.Fatalf("expected decode error, actual error: '%v'", err) } @@ -639,12 +655,14 @@ func TestAccountImportDecodeFeeProofSigError(t *testing.T) { func TestAccountImportAccountPaidError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) rig.queueConfig() - rig.db.accountPaidErr = tErr - err := tCore.AccountImport(tPW, account) + rig.db.storeAccountProofErr = tErr + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, dbErr) { t.Fatalf("expected db error, actual error: '%v'", err) } @@ -652,12 +670,14 @@ func TestAccountImportAccountPaidError(t *testing.T) { func TestAccountImportAccountCreateAccountError(t *testing.T) { rig := newTestRig() + rig.db.acctErr = db.ErrAcctNotFound + defer rig.shutdown() tCore := rig.core - host := tCore.conns[tDexHost].acct.host - account := buildTestAccount(host) + delete(tCore.conns, tDexHost) + account := buildTestAccount(tDexHost) rig.queueConfig() rig.db.createAccountErr = tErr - err := tCore.AccountImport(tPW, account) + err := tCore.AccountImport(tPW, account, nil) if !errorHasCode(err, dbErr) { t.Fatalf("expected db error, actual error: '%v'", err) } diff --git a/client/core/bond.go b/client/core/bond.go new file mode 100644 index 0000000000..ab3f7594de --- /dev/null +++ b/client/core/bond.go @@ -0,0 +1,775 @@ +package core + +import ( + "bytes" + "context" + "errors" + "fmt" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/db" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/encrypt" + "decred.org/dcrdex/dex/keygen" + "decred.org/dcrdex/dex/msgjson" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/hdkeychain/v3" +) + +const ( + // lockTimeLimit is an upper limit on the allowable bond lockTime. + lockTimeLimit = 120 * 24 * time.Hour +) + +func cutBond(bonds []*db.Bond, i int) []*db.Bond { // input slice modified + bonds[i] = bonds[len(bonds)-1] + bonds[len(bonds)-1] = nil + bonds = bonds[:len(bonds)-1] + return bonds +} + +func (c *Core) watchExpiredBonds(ctx context.Context) { + t := time.NewTicker(20 * time.Second) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c.refundExpiredBonds(ctx) + } + } +} + +func (c *Core) refundExpiredBonds(ctx context.Context) { + // 1. Refund bonds with passed lockTime. + // 2. Move bonds that are expired according to DEX bond expiry into + // expiredBonds (lockTime= reqConfs { // don't bother waiting for a block + go c.postAndConfirmBond(dc, bond) + return + } + + if coinNotFound { + // Broadcast the bond and start waiting for confs. + c.log.Infof("Broadcasting bond %v (%s), data = %x.\n\n"+ + "BACKUP refund tx paying to current wallet: %x\n\n", + coinIDStr, unbip(bond.AssetID), bond.Data, bond.RedeemTx) + if _, err = wallet.SendTransaction(bond.SignedTx); err != nil { + c.log.Warnf("Failed to broadcast bond txn: %v") + // TODO: screen inputs if the tx is trying to spend spent outputs + // (invalid bond transaction that should be abandoned). + } + c.updateAssetBalance(bond.AssetID) + } + + trigger := func() (bool, error) { + // Retrieve the current wallet in case it was reconfigured. + wallet, _ := c.wallet(assetID) // We already know the wallet is there by now. + confs, err := wallet.RegFeeConfirmations(c.ctx, coinID) + if err != nil && !errors.Is(err, asset.CoinNotFoundError) { + return false, fmt.Errorf("Error getting confirmations for %s: %w", coinIDStr, err) + } + + if confs != lastConfs { + c.updateAssetBalance(assetID) + lastConfs = confs + } + + if confs < reqConfs { + details := fmt.Sprintf("Fee payment confirmations %v/%v", confs, reqConfs) + c.notify(newBondPostNoteWithConfirmations(TopicRegUpdate, string(TopicRegUpdate), + details, db.Data, assetID, int32(confs), host)) + } + + return confs >= reqConfs, nil + } + + c.wait(coinID, assetID, trigger, func(err error) { + if err != nil { + details := fmt.Sprintf("Error encountered while waiting for bond confirms for %s: %v", host, err) + c.notify(newBondPostNote(TopicBondPostError, string(TopicBondPostError), + details, db.ErrorLevel, host)) + return + } + + c.log.Infof("DEX %v bond txn %s now has %d confirmations. Submitting postbond request...", + host, coinIDStr, reqConfs) + + c.postAndConfirmBond(dc, bond) + }) +} + +func deriveBondKey(seed []byte, assetID, bondIndex uint32) (*secp256k1.PrivateKey, error) { + if bondIndex >= hdkeychain.HardenedKeyStart { + return nil, fmt.Errorf("maximum key generation reached, cannot generate %dth key", bondIndex) + } + + kids := []uint32{ + hdKeyPurposeBonds, + assetID + hdkeychain.HardenedKeyStart, + bondIndex, + } + extKey, err := keygen.GenDeepChild(seed, kids) + if err != nil { + return nil, fmt.Errorf("GenDeepChild error: %w", err) + } + privB, err := extKey.SerializedPrivKey() + if err != nil { + return nil, fmt.Errorf("SerializedPrivKey error: %w", err) + } + priv := secp256k1.PrivKeyFromBytes(privB) + return priv, nil +} + +func (c *Core) nextBondKey(crypter encrypt.Crypter, assetID uint32) (*secp256k1.PrivateKey, error) { + creds := c.creds() + if creds == nil { + return nil, errors.New("app not initialized") + } + seed, err := crypter.Decrypt(creds.EncSeed) + if err != nil { + return nil, fmt.Errorf("seed decryption error: %w", err) + } + defer encode.ClearBytes(seed) + + nextBondKeyIndex, err := c.db.NextBondKeyIndex(assetID) + if err != nil { + return nil, fmt.Errorf("NextBondIndex: %v", err) + } + + return deriveBondKey(seed, assetID, nextBondKeyIndex) +} + +// 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 +// for acceptance to the server. A TopicBondConfirmed is emitted when the +// fully-confirmed bond is accepted. Before the transaction is broadcasted, a +// prevalidatebond request is sent to ensure the transaction is compliant and +// (and that the intended server is actually online!). PostBond may be used to +// create a new account with a bond, or to top-up bond on an existing account. +// If the account is not yet configured in Core, account discovery will be +// performed prior to posting a new bond. If account discovery finds an existing +// account, the connection is established but no additional bond is posted. If +// no account is discovered on the server, the account is created locally and +// bond is posted to create the account. +func (c *Core) PostBond(form *PostBondForm) (*PostBondResult, error) { + // Make sure the app has been initialized. + if !c.IsInitialized() { + return nil, fmt.Errorf("app not initialized") + } + + // Get the wallet to author the transaction. Default to DCR. + bondAssetID := uint32(42) + if form.Asset != nil { + bondAssetID = *form.Asset + } + bondAssetSymbol := dex.BipIDSymbol(bondAssetID) + wallet, err := c.connectedWallet(bondAssetID) + if err != nil { + return nil, fmt.Errorf("cannot connect to %s wallet to pay fee: %w", bondAssetSymbol, err) + } + + 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) + } + + // Check the app password. + crypter, err := c.encryptionKey(form.AppPass) + if err != nil { + return nil, codedError(passwordErr, err) + } + defer crypter.Close() + if form.Addr == "" { + return nil, newError(emptyHostErr, "no dex address specified") + } + host, err := addrHost(form.Addr) + if err != nil { + return nil, newError(addressParseErr, "error parsing address: %v", err) + } + + var success bool + + c.connMtx.RLock() + dc, acctExists := c.conns[host] + c.connMtx.RUnlock() + if acctExists { + if dc.acct.locked() { // require authDEX first to reconcile any existing bond statuses + return nil, newError(acctKeyErr, "acct locked %s (login first)", form.Addr) + } + } else { + // New DEX connection. + cert, err := parseCert(host, form.Cert, c.net) + if err != nil { + return nil, newError(fileReadErr, "failed to read certificate file from %s: %v", cert, err) + } + dc, err = c.connectDEX(&db.AccountInfo{ + Host: host, + Cert: cert, + }) + if err != nil { + if dc != nil { + // Stop (re)connect loop, which may be running even if err != nil. + dc.connMaster.Disconnect() + } + return nil, codedError(connectionErr, err) + } + + // Close the connection to the dex server if the registration fails. + defer func() { + if !success { + dc.connMaster.Disconnect() + } + }() + + paid, err := c.discoverAccount(dc, crypter) + if err != nil { + return nil, err + } + if paid { + success = true + // The listen goroutine is already running, now track the conn. + c.connMtx.Lock() + c.conns[dc.acct.host] = dc + c.connMtx.Unlock() + return &PostBondResult{ /* no new bond */ }, nil + } + // dc.acct is now configured with encKey, privKey, and id for a new + // (unregistered) account. + } + + // Ensure this DEX supports this asset for bond, and get the required + // confirmations and bond amount. + bondAsset, bondExpiry := dc.bondAsset(bondAssetID) + if bondAsset == nil { + return nil, newError(assetSupportErr, "dex server does not support fidelity bonds in asset %q", bondAssetSymbol) + } + bondValidity := time.Duration(bondExpiry) * time.Second // bond lifetime + + lockTime := time.Now().Add(2 * bondValidity).Truncate(time.Second) // default lockTime is double + if form.LockTime > 0 { + lockTime = time.Unix(int64(form.LockTime), 0) + } + expireTime := time.Now().Add(bondValidity) // when the server would expire the bond + if lockTime.Before(expireTime) { + return nil, newError(bondTimeErr, "lock time of %d has already passed the server's expiry time of %v (bond expiry %d)", + form.LockTime, expireTime, bondExpiry) + } + if lockTime.Add(-time.Minute).Before(expireTime) { + return nil, newError(bondTimeErr, "lock time of %d is less than a minute from the server's expiry time of %v (bond expiry %d)", + form.LockTime, expireTime, bondExpiry) + } + if lockDur := time.Until(lockTime); lockDur > lockTimeLimit { + return nil, newError(bondTimeErr, "excessive lock time (%v>%v)", lockDur, lockTimeLimit) + } + + // Check that the bond amount is non-zero. + if form.Bond == 0 { + return nil, newError(bondAmtErr, "zero registration fees not allowed") + } + // Check that the bond amount matches the caller's expectations. + if form.Bond < bondAsset.Amt { + return nil, newError(bondAmtErr, "specified bond amount is less than the DEX-provided amount. %d < %d", + form.Bond, bondAsset.Amt) + } + if rem := form.Bond % bondAsset.Amt; rem != 0 { + return nil, newError(bondAmtErr, "specified bond amount is not a multiple of the DEX-provided amount. %d %% %d = %d", + form.Bond, bondAsset.Amt, rem) + } + strength := form.Bond / bondAsset.Amt + + // 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. + priv, err := c.nextBondKey(crypter, bondAssetID) + if err != nil { + return nil, fmt.Errorf("bond key derivation failed: %v", err) + } + defer priv.Zero() + acctID := dc.acct.ID() + bond, err := wallet.MakeBondTx(bondAsset.Version, form.Bond, lockTime, priv, acctID[:]) + if err != nil { + return nil, codedError(registerErr, err) + } + + // Do prevalidatebond with the *unsigned* txn. + if err = c.preValidateBond(dc, bond); err != nil { + return nil, err + } + + reqConfs := bondAsset.Confs + bondCoinStr := coinIDString(bond.AssetID, bond.CoinID) + c.log.Infof("DEX %v has validated our bond %v (%s) with strength %d. %d confirmations required to trade.", + host, bondCoinStr, unbip(bond.AssetID), strength, reqConfs) + + // Store the account and bond info. + dbBond := &db.Bond{ + Version: bond.Version, + AssetID: bond.AssetID, + CoinID: bond.CoinID, + UnsignedTx: bond.UnsignedTx, + SignedTx: bond.SignedTx, + Data: bond.Data, + Amount: form.Bond, + LockTime: uint64(lockTime.Unix()), + PrivKey: bond.BondPrivKey, + RefundTx: bond.RedeemTx, + // Confirmed and Refunded are false (new bond tx) + } + + if acctExists { + err = c.db.AddBond(host, dbBond) + if err != nil { + return nil, fmt.Errorf("failed to store bond %v (%s) for dex %v: %w", + bondCoinStr, unbip(bond.AssetID), host, err) + } + } else { + ai := &db.AccountInfo{ + Host: host, + Cert: dc.acct.cert, + DEXPubKey: dc.acct.dexPubKey, + EncKeyV2: dc.acct.encKey, + Bonds: []*db.Bond{dbBond}, + } + err = c.db.CreateAccount(ai) + if err != nil { + return nil, fmt.Errorf("failed to store account %v for dex %v: %w", + dc.acct.id, host, err) + } + c.connMtx.Lock() + c.conns[dc.acct.host] = dc + c.connMtx.Unlock() + } + + dc.acct.authMtx.Lock() + dc.acct.pendingBonds = append(dc.acct.pendingBonds, dbBond) + dc.acct.authMtx.Unlock() + + success = true // no errors after this + + // Broadcast the bond and start waiting for confs. + c.log.Infof("Broadcasting bond %v (%s) with lock time %v, data = %x.\n\n"+ + "BACKUP refund tx paying to current wallet: %x\n\n", + bondCoinStr, unbip(bond.AssetID), lockTime, bond.Data, bond.RedeemTx) + if bondCoinCast, err := wallet.SendTransaction(bond.SignedTx); err != nil { + c.log.Warnf("Failed to broadcast bond txn (%v). Tx bytes: %x", bond.SignedTx) + } else if !bytes.Equal(bond.CoinID, bondCoinCast) { + c.log.Warnf("Broadcasted bond %v; was expecting %v!", + coinIDString(bond.AssetID, bondCoinCast), bondCoinStr) + } + + c.updateAssetBalance(bond.AssetID) + + // Start waiting for reqConfs. + details := fmt.Sprintf("Waiting for %d confirmations to post bond %v (%s) to %s", + reqConfs, bondCoinStr, unbip(bond.AssetID), dc.acct.host) + c.notify(newBondPostNoteWithConfirmations(TopicBondConfirming, string(TopicBondConfirming), + details, db.Success, bond.AssetID, 0, dc.acct.host)) + // Set up the coin waiter, which watches confirmations so the user knows + // when to expect their account to be marked paid by the server. + c.monitorBondConfs(dc, bond, reqConfs) + + return &PostBondResult{BondID: bondCoinStr, ReqConfirms: uint16(reqConfs)}, nil +} + +func (c *Core) bondConfirmed(dc *dexConnection, assetID uint32, coinID []byte, newTier int64) error { + // Update dc.acct.{bonds,pendingBonds,tier} under authMtx lock. + var foundPending, foundConfirmed bool + dc.acct.authMtx.Lock() + for i, bond := range dc.acct.pendingBonds { + if bond.AssetID == assetID && bytes.Equal(bond.CoinID, coinID) { + // Delete the bond from pendingBonds and move it to (active) bonds. + dc.acct.pendingBonds = cutBond(dc.acct.pendingBonds, i) + dc.acct.bonds = append(dc.acct.bonds, bond) + bond.Confirmed = true // not necessary, just for consistency with slice membership + foundPending = true + break + } + } + if !foundPending { + for _, bond := range dc.acct.bonds { + if bond.AssetID == assetID && bytes.Equal(bond.CoinID, coinID) { + foundConfirmed = true + break + } + } + } + + dc.acct.tier = newTier + isAuthed := dc.acct.isAuthed + dc.acct.authMtx.Unlock() + + bondIDStr := coinIDString(assetID, coinID) + if foundPending { + // Set bond confirmed in the DB. + err := c.db.ConfirmBond(dc.acct.host, assetID, coinID) + if err != nil { + return fmt.Errorf("db.ConfirmBond failure: %w", err) + } + c.log.Infof("Bond %s (%s) confirmed.", bondIDStr, unbip(assetID)) + details := fmt.Sprintf("New tier = %d.", newTier) // TODO: format to subject,details + c.notify(newBondPostNoteWithTier(TopicBondConfirmed, string(TopicBondConfirmed), details, db.Success, dc.acct.host, newTier)) + } else if !foundConfirmed { + c.log.Errorf("bondConfirmed: Bond %s (%s) not found", bondIDStr, unbip(assetID)) + // just try to authenticate... + } // else already found confirmed (no-op) + + // If we were not previously authenticated, we can infer that this was the + // bond that created the account server-side, otherwise this was a top-up. + if isAuthed { + return nil // already logged in + } + + if dc.acct.locked() { + c.log.Info("Login to check current account tier with newly confirmed bond %v.", bondIDStr) + return nil + } + + err := c.authDEX(dc) + if err != nil { + details := fmt.Sprintf("Bond confirmed, but failed to authenticate connection: %v", err) // TODO: format to subject,details + c.notify(newDEXAuthNote(TopicDexAuthError, string(TopicDexAuthError), dc.acct.host, false, details, db.ErrorLevel)) + return err + } + + details := fmt.Sprintf("New tier = %d", newTier) // TODO: format to subject,details + c.notify(newBondPostNoteWithTier(TopicAccountRegistered, string(TopicAccountRegistered), + details, db.Success, dc.acct.host, newTier)) // possibly redundant with SubjectBondConfirmed + + return nil +} + +func (c *Core) bondExpired(dc *dexConnection, assetID uint32, coinID []byte, newTier int64) error { + // Update dc.acct.{bonds,tier} under authMtx lock. + var found bool + dc.acct.authMtx.Lock() + for i, bond := range dc.acct.bonds { + if bond.AssetID == assetID && bytes.Equal(bond.CoinID, coinID) { + // Delete the bond from bonds and move it to expiredBonds. + dc.acct.bonds = cutBond(dc.acct.bonds, i) + if len(bond.RefundTx) > 0 || len(bond.PrivKey) > 0 { + dc.acct.expiredBonds = append(dc.acct.expiredBonds, bond) // we'll wait for lockTime to pass to refund + } else { + c.log.Warnf("Dropping expired bond with no known keys or refund transaction. "+ + "This was a placeholder for an unknown bond reported to use by the server. "+ + "Bond ID: %x (%s)", coinIDString(bond.AssetID, bond.CoinID), unbip(bond.AssetID)) + } + found = true + break + } + } + if !found { // refundExpiredBonds may have gotten to it first + for _, bond := range dc.acct.expiredBonds { + if bond.AssetID == assetID && bytes.Equal(bond.CoinID, coinID) { + found = true + break + } + } + } + + dc.acct.tier = newTier + dc.acct.authMtx.Unlock() + + bondIDStr := coinIDString(assetID, coinID) + if !found { + c.log.Warnf("bondExpired: Bond %s (%s) in bondexpired message not found locally (already refunded?).", + bondIDStr, unbip(assetID)) + } + + details := fmt.Sprintf("New tier = %d.", newTier) + c.notify(newBondPostNoteWithTier(TopicBondExpired, string(TopicBondExpired), + details, db.Success, dc.acct.host, newTier)) + + return nil +} diff --git a/client/core/core.go b/client/core/core.go index 651e000b1a..8efaaa06a6 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -198,6 +198,15 @@ func (dc *dexConnection) feeAsset(assetID uint32) *msgjson.FeeAsset { return dc.cfg.RegFees[symb] } +func (dc *dexConnection) bondAsset(assetID uint32) (*msgjson.BondAsset, uint64) { + assetSymb := dex.BipIDSymbol(assetID) + dc.cfgMtx.RLock() + defer dc.cfgMtx.RUnlock() + bondExpiry := dc.cfg.BondExpiry + bondAsset := dc.cfg.BondAssets[assetSymb] + return bondAsset, bondExpiry // bondAsset may be nil +} + // marketConfig is the market's configuration, as returned by the server in the // 'config' response. func (dc *dexConnection) marketConfig(mktID string) *msgjson.Market { @@ -406,6 +415,10 @@ func (dc *dexConnection) exchangeInfo() *Exchange { bondAssets := make(map[string]*BondAsset, len(cfg.BondAssets)) for symb, bondAsset := range cfg.BondAssets { + if assetID, ok := dex.BipSymbolID(symb); !ok || assetID != bondAsset.ID { + dc.log.Warnf("Invalid bondAssets config with mismatched asset symbol %q and ID %d", + symb, bondAsset.ID) + } coreBondAsset := BondAsset(*bondAsset) // convert msgjson.BondAsset to core.BondAsset bondAssets[symb] = &coreBondAsset } @@ -438,7 +451,7 @@ func (dc *dexConnection) exchangeInfo() *Exchange { dc.acct.authMtx.RLock() // TODO: List bonds in core.Exchange. For now, just tier. - // bondsPending := len(dc.acct.pendingBonds) > 0 + bondsPending := len(dc.acct.pendingBonds) > 0 tier := dc.acct.tier dc.acct.authMtx.RUnlock() @@ -452,7 +465,7 @@ func (dc *dexConnection) exchangeInfo() *Exchange { ConnectionStatus: dc.status(), CandleDurs: cfg.BinSizes, Tier: tier, - BondsPending: false, + BondsPending: bondsPending, // TODO: Bonds // Legacy reg fee (V0PURGE) @@ -1187,7 +1200,7 @@ func (c *Core) connectedDEX(addr string) (*dexConnection, error) { } if !connected { - return nil, fmt.Errorf("currently disconnected from %s. Cannot place order", dc.acct.host) + return nil, fmt.Errorf("currently disconnected from %s", dc.acct.host) } return dc, nil } @@ -1524,6 +1537,13 @@ func (c *Core) Run(ctx context.Context) { c.ratesMtx.Unlock() } + // Start bond supervisor. + c.wg.Add(1) + go func() { + defer c.wg.Done() + c.watchExpiredBonds(ctx) + }() + c.wg.Wait() // block here until all goroutines except DB complete // Stop the DB after dexConnections and other goroutines are done. @@ -2886,7 +2906,11 @@ func (c *Core) OpenWallet(assetID uint32, appPW []byte) error { state.Symbol, balances.Available, balances.Locked, balances.ContractLocked, state.Address) - go c.checkUnpaidFees(wallet) + c.wg.Add(1) + func() { + defer c.wg.Done() + go c.checkUnpaidFees(wallet) + }() c.notify(newWalletStateNote(state)) @@ -3526,10 +3550,11 @@ func (c *Core) discoverAccount(dc *dexConnection, crypter encrypt.Crypter) (bool "with the same credentials to complete registration with " + "the previously-assigned fee address and asset ID.") } - return false, nil // all good, just go register now + return false, nil // all good, just go register/postbond now } return false, newError(authErr, "unexpected authDEX error: %w", err) } + // do not skip key if tier is 0 and bonds will be used if dc.acct.tier < 0 || (dc.acct.tier < 1 && dc.apiVersion() < serverdex.BondAPIVersion) { c.log.Infof("HD account key for %s has tier %d (not able to trade). Deriving another account key.", @@ -3550,26 +3575,27 @@ func (c *Core) discoverAccount(dc *dexConnection, crypter encrypt.Crypter) (bool } err := c.db.CreateAccount(&db.AccountInfo{ - Host: dc.acct.host, - Cert: dc.acct.cert, - DEXPubKey: dc.acct.dexPubKey, - EncKeyV2: dc.acct.encKey, - LegacyEncKey: nil, - FeeAssetID: dc.acct.feeAssetID, - FeeCoin: dc.acct.feeCoin, - // Paid set with AccountPaid below. + Host: dc.acct.host, + Cert: dc.acct.cert, + DEXPubKey: dc.acct.dexPubKey, + EncKeyV2: dc.acct.encKey, + Bonds: dc.acct.bonds, // any reported by server + LegacyFeeAssetID: dc.acct.feeAssetID, + LegacyFeeCoin: dc.acct.feeCoin, }) if err != nil { return false, fmt.Errorf("error saving restored account: %w", err) } - err = c.db.AccountPaid(&db.AccountProof{ - Host: dc.acct.host, - Stamp: 54321, - Sig: []byte("RECOVERY SIGNATURE"), - }) - if err != nil { - return false, fmt.Errorf("error marking recovered account as paid: %w", err) + if dc.acct.legacyFeePaid { + err = c.db.StoreAccountProof(&db.AccountProof{ + Host: dc.acct.host, + Stamp: 54321, + Sig: []byte("RECOVERY SIGNATURE"), + }) + if err != nil { + return false, fmt.Errorf("error marking recovered account as paid: %w", err) + } } return true, nil // great, just stay connected @@ -3725,10 +3751,9 @@ func (c *Core) EstimateRegistrationTxFee(host string, certI interface{}, assetID // Register registers an account with a new DEX. If an error occurs while // fetching the DEX configuration or creating the fee transaction, it will be -// returned immediately. -// A thread will be started to wait for the requisite confirmations and send -// the fee notification to the server. Any error returned from that thread is -// sent as a notification. +// returned immediately. A goroutine will be started to wait for the requisite +// confirmations and send the fee notification to the server. Any error returned +// from that goroutine is sent as a notification. func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { // Make sure the app has been initialized. This condition would error when // attempting to retrieve the encryption key below as well, but the @@ -3824,7 +3849,8 @@ func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { c.conns[dc.acct.host] = dc c.connMtx.Unlock() - return &RegisterResult{FeeID: hex.EncodeToString(dc.acct.feeCoin), ReqConfirms: 0}, nil + feeCoinStr := coinIDString(dc.acct.feeAssetID, dc.acct.feeCoin) + return &RegisterResult{FeeID: feeCoinStr, ReqConfirms: 0}, nil } // dc.acct is now configured with encKey, privKey, and id for a new // (unregistered) account. @@ -3856,7 +3882,8 @@ func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { registrationComplete = true // register already promoted the connection - return &RegisterResult{FeeID: hex.EncodeToString(dc.acct.feeCoin), ReqConfirms: 0}, nil + feeCoinStr := coinIDString(dc.acct.feeAssetID, dc.acct.feeCoin) + return &RegisterResult{FeeID: feeCoinStr, ReqConfirms: 0}, nil } if suspended { // would have gotten this from discoverAccount return nil, fmt.Errorf("unexpectedly tried to register a suspended account - try again") @@ -3899,13 +3926,13 @@ func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { c.connMtx.Unlock() err = c.db.CreateAccount(&db.AccountInfo{ - Host: dc.acct.host, - Cert: dc.acct.cert, - DEXPubKey: dc.acct.dexPubKey, - EncKeyV2: dc.acct.encKey, - FeeAssetID: dc.acct.feeAssetID, - FeeCoin: dc.acct.feeCoin, - // Paid set with AccountPaid after notifyFee. + Host: dc.acct.host, + Cert: dc.acct.cert, + DEXPubKey: dc.acct.dexPubKey, + EncKeyV2: dc.acct.encKey, + LegacyFeeAssetID: dc.acct.feeAssetID, + LegacyFeeCoin: dc.acct.feeCoin, + // LegacyFeePaid set with AccountPaid after notifyFee. }) if err != nil { c.log.Errorf("error saving account: %v\n", err) @@ -3998,13 +4025,13 @@ func (c *Core) register(dc *dexConnection, assetID uint32) (regRes *msgjson.Regi } err = c.db.CreateAccount(&db.AccountInfo{ - Host: dc.acct.host, - Cert: dc.acct.cert, - DEXPubKey: dc.acct.dexPubKey, - EncKeyV2: dc.acct.encKey, - FeeAssetID: dc.acct.feeAssetID, - FeeCoin: dc.acct.feeCoin, - // Paid set with AccountPaid below. + Host: dc.acct.host, + Cert: dc.acct.cert, + DEXPubKey: dc.acct.dexPubKey, + EncKeyV2: dc.acct.encKey, + LegacyFeeAssetID: dc.acct.feeAssetID, + LegacyFeeCoin: dc.acct.feeCoin, + // LegacyFeePaid set with AccountPaid below. }) if err != nil { // Shouldn't let the client trade with this server if we can't store @@ -4014,7 +4041,7 @@ func (c *Core) register(dc *dexConnection, assetID uint32) (regRes *msgjson.Regi return nil, true, false, fmt.Errorf("error saving restored account: %w", err) } - err = c.db.AccountPaid(&db.AccountProof{ + err = c.db.StoreAccountProof(&db.AccountProof{ Host: dc.acct.host, Stamp: 54321, Sig: []byte("RECOVERY SIGNATURE"), @@ -4630,11 +4657,7 @@ func (c *Core) MaxSell(host string, base, quote uint32) (*MaxOrderEstimate, erro } // initializeDEXConnections connects to the DEX servers in the conns map and -// authenticates the connection. If registration is incomplete, reFee is run and -// the connection will be authenticated once the `notifyfee` request is sent. -// If an account is not found on the dex server upon dex authentication the -// account is disabled and the corresponding entry in c.conns is removed -// which will result in the user being prompted to register again. +// authenticates the connection. This is done on Login. func (c *Core) initializeDEXConnections(crypter encrypt.Crypter) { var wg sync.WaitGroup conns := c.dexConnections() @@ -4645,19 +4668,14 @@ func (c *Core) initializeDEXConnections(crypter encrypt.Crypter) { err := dc.acct.unlock(crypter) if err != nil { subject, details := c.formatDetails(TopicAccountUnlockError, dc.acct.host, err) - c.notify(newFeePaymentNote(TopicAccountUnlockError, subject, details, db.ErrorLevel, dc.acct.host)) + c.notify(newFeePaymentNote(TopicAccountUnlockError, subject, details, db.ErrorLevel, dc.acct.host)) // newDEXAuthNote? continue } if dc.acct.authed() { continue // authDEX already done } - if !dc.acct.feePaid() { - if len(dc.acct.feeCoin) == 0 { - subject, details := c.formatDetails(TopicFeeCoinError, dc.acct.host) - c.notify(newFeePaymentNote(TopicFeeCoinError, subject, details, db.ErrorLevel, dc.acct.host)) - continue - } + if dc.acct.feePending() { // V0PURGE // Try to unlock the fee wallet, which should run the reFee cycle, and // in turn will run authDEX. feeWallet, err := c.connectedWallet(dc.acct.feeAssetID) @@ -4678,6 +4696,7 @@ func (c *Core) initializeDEXConnections(crypter encrypt.Crypter) { c.reFee(feeWallet, dc) continue } + // Pending bonds will be handled by authDEX. // If the connection is down, authDEX will fail on Send. if dc.IsDown() { @@ -4699,6 +4718,7 @@ func (c *Core) initializeDEXConnections(crypter encrypt.Crypter) { } }(dc) } + wg.Wait() } @@ -4729,6 +4749,7 @@ func (c *Core) wait(coinID []byte, assetID uint32, trigger func() (bool, error), } } +// V0PURGE func (c *Core) notifyFee(dc *dexConnection, coinID []byte) error { if dc.acct.locked() { return fmt.Errorf("%s account locked. cannot notify fee. log in first", dc.acct.host) @@ -4763,7 +4784,7 @@ func (c *Core) notifyFee(dc *dexConnection, coinID []byte) error { if err != nil { c.log.Warnf("Account was registered, but DEX signature could not be verified: %v", err) } - errChan <- c.db.AccountPaid(&db.AccountProof{ + errChan <- c.db.StoreAccountProof(&db.AccountProof{ Host: dc.acct.host, Stamp: req.Time, Sig: ack.Sig, @@ -5691,8 +5712,43 @@ func (c *Core) cancelOrder(oid order.OrderID) error { return fmt.Errorf("Cancel: failed to find order %s", oid) } +func assetBond(bond *db.Bond) *asset.Bond { + return &asset.Bond{ + Version: bond.Version, + AssetID: bond.AssetID, + Amount: bond.Amount, + CoinID: bond.CoinID, + Data: bond.Data, + BondPrivKey: bond.PrivKey, + SignedTx: bond.SignedTx, + UnsignedTx: bond.UnsignedTx, + RedeemTx: bond.RefundTx, + } +} + +// bondKey creates a unique map key for a bond by its asset ID and coin ID. +func bondKey(assetID uint32, coinID []byte) string { + return string(append(encode.Uint32Bytes(assetID), coinID...)) +} + // authDEX authenticates the connection for a DEX. func (c *Core) authDEX(dc *dexConnection) error { + dc.cfgMtx.RLock() + cfg := dc.cfg + dc.cfgMtx.RUnlock() + if cfg == nil { // reconnect loop may be running + return fmt.Errorf("dex connection not usable prior to config request") + } + bondAssets := cfg.BondAssets + + // Copy the local bond slices since bondConfirmed will modify them. + dc.acct.authMtx.RLock() + localActiveBonds := make([]*db.Bond, len(dc.acct.bonds)) + copy(localActiveBonds, dc.acct.bonds) + localPendingBonds := make([]*db.Bond, len(dc.acct.pendingBonds)) + copy(localPendingBonds, dc.acct.pendingBonds) + dc.acct.authMtx.RUnlock() + // Prepare and sign the message for the 'connect' route. acctID := dc.acct.ID() payload := &msgjson.Connect{ @@ -5724,8 +5780,22 @@ func (c *Core) authDEX(dc *dexConnection) error { if err != nil { return err } + // Check the response error. err = <-errChan + // AccountNotFoundError may signal we have an initial bond to post. + var mErr *msgjson.Error + if errors.As(err, &mErr) && mErr.Code == msgjson.AccountNotFoundError { + for _, dbBond := range localPendingBonds { + symb := dex.BipIDSymbol(dbBond.AssetID) + bondAsset := bondAssets[symb] + if bondAsset == nil { + c.log.Warnf("authDEX: No info on bond asset %s. Cannot start postbond waiter.", symb) + continue + } + c.monitorBondConfs(dc, assetBond(dbBond), bondAsset.Confs) + } + } if err != nil { return fmt.Errorf("'connect' error: %w", err) } @@ -5760,6 +5830,108 @@ func (c *Core) authDEX(dc *dexConnection) error { // call authDEX if not flagged as such. dc.acct.auth(tier, legacyFeePaid) + // Check active and pending bonds, comparing against result.ActiveBonds. For + // pendingBonds, rebroadcast and start waiter to postBond. For + // (locally-confirmed) bonds that are not in connectResp.Bonds, postBond. + + // Start by mapping the server-reported bonds: + remoteLiveBonds := make(map[string]*msgjson.Bond) + for _, bond := range result.ActiveBonds { + remoteLiveBonds[bondKey(bond.AssetID, bond.CoinID)] = bond + } + + // Identify bonds we consider live that are either pending or missing from + // server. In either case, do c.monitorBondConfs (will be immediate postBond + // and bondConfirmed if at required confirmations). + for _, bond := range localActiveBonds { + key := bondKey(bond.AssetID, bond.CoinID) + symb := dex.BipIDSymbol(bond.AssetID) + bondIDStr := coinIDString(bond.AssetID, bond.CoinID) + + _, found := remoteLiveBonds[key] + if found { + continue // good, it's live server-side too + } + + c.log.Warnf("Locally-active bond %v (%s) not reported by server. Will repost...", + bondIDStr, symb) // unexpected, but postbond again + + bondAsset := bondAssets[symb] + if bondAsset == nil { + c.log.Warnf("Server no longer supports %v as a bond asset!", symb) + continue + } + + // Unknown on server. postBond at required confs. + c.log.Infof("Posting locally-confirmed bond %v (%s).", bondIDStr, symb) + c.monitorBondConfs(dc, assetBond(bond), bondAsset.Confs) + continue + } + + // Identify bonds we consider pending that are either live or missing from + // server. If live on server, do c.bondConfirmed. If missing, do + // c.monitorBondConfs. + for _, bond := range localPendingBonds { + key := bondKey(bond.AssetID, bond.CoinID) + symb := dex.BipIDSymbol(bond.AssetID) + bondIDStr := coinIDString(bond.AssetID, bond.CoinID) + + _, found := remoteLiveBonds[key] + if found { + // It's live server-side. Confirm it locally. + c.log.Debugf("Confirming pending bond %v that is confirmed server side", bondIDStr) + if err = c.bondConfirmed(dc, bond.AssetID, bond.CoinID, tier /* no change */); err != nil { + c.log.Errorf("Unable to confirm bond %s: %v", bondIDStr, err) + } + continue + } + + c.log.Debugf("Starting coin waiter for pending bond %v (%s)", bondIDStr, symb) + bondAsset := bondAssets[symb] + if bondAsset == nil { + c.log.Warnf("Server no longer supports %v as a bond asset!", symb) + continue // will retry, eventually refund + } + + // Still pending on server. Start waiting for confs. + c.log.Debugf("Preparing to post pending bond %v (%s).", bondIDStr, symb) + c.monitorBondConfs(dc, assetBond(bond), bondAsset.Confs) + } + + localBondMap := make(map[string]struct{}, len(localActiveBonds)+len(localPendingBonds)) + for _, dbBond := range localActiveBonds { + localBondMap[bondKey(dbBond.AssetID, dbBond.CoinID)] = struct{}{} + } + for _, dbBond := range localPendingBonds { + localBondMap[bondKey(dbBond.AssetID, dbBond.CoinID)] = struct{}{} + } + + for _, bond := range result.ActiveBonds { + key := bondKey(bond.AssetID, bond.CoinID) + if _, found := localBondMap[key]; found { + continue + } + // EXPERIMENT: Server knows of a bond we do not! Store what we can for + // tier accounting, but we can't redeem it (or we have already redeemed + // it and thus not loaded it already). We'll make en entry in + // dc.acct.bonds just for tier accounting to match server. + dbBond := &db.Bond{ + AssetID: bond.AssetID, + CoinID: bond.CoinID, + Amount: bond.Amount, + LockTime: bond.Expiry, // trust? + // PrivKey/RefundTx unknown. If this is really our bond and not + // garbage from an untrusted server, the user better have a backup! + Confirmed: true, + } + + symb := dex.BipIDSymbol(bond.AssetID) + bondIDStr := coinIDString(bond.AssetID, bond.CoinID) + c.log.Warnf("Unknown bond reported by server: %v (%d)", bondIDStr, symb) + + dc.acct.bonds = append(dc.acct.bonds, dbBond) // for tier accounting, but we cannot redeem it + } + // Associate the matches with known trades. matches, _, err := dc.parseMatches(result.ActiveMatches, false) if err != nil { @@ -5999,13 +6171,13 @@ func (c *Core) initialize() { // retry / keepalive loop is active. If there was already a dexConnection, it is // first stopped. func (c *Core) connectAccount(acct *db.AccountInfo) (dc *dexConnection, connected bool) { - if !acct.Paid && len(acct.FeeCoin) == 0 { - // Register should have set this when creating the account that was - // obtained via db.Accounts. - c.log.Warnf("Incomplete registration without fee payment detected for DEX %s. "+ - "Discarding account.", acct.Host) - return - } + // if !acct.Paid && len(acct.FeeCoin) == 0 { + // // Register should have set this when creating the account that was + // // obtained via db.Accounts. + // c.log.Warnf("Incomplete registration without fee payment detected for DEX %s. "+ + // "Discarding account.", acct.Host) + // return + // } host, err := addrHost(acct.Host) if err != nil { @@ -6049,15 +6221,17 @@ func (c *Core) connectAccount(acct *db.AccountInfo) (dc *dexConnection, connecte } // feeLock is used to ensure that no more than one reFee check is running at a -// time. +// time. (V0PURGE) var feeLock uint32 // checkUnpaidFees checks whether the registration fee info has an acceptable -// state, and tries to rectify any inconsistencies. +// state, and tries to rectify any inconsistencies. (V0PURGE) func (c *Core) checkUnpaidFees(wallet *xcWallet) { if !atomic.CompareAndSwapUint32(&feeLock, 0, 1) { return } + defer atomic.StoreUint32(&feeLock, 0) + var wg sync.WaitGroup for _, dc := range c.dexConnections() { if dc.acct.feePaid() { @@ -6067,8 +6241,8 @@ func (c *Core) checkUnpaidFees(wallet *xcWallet) { continue // different wallet } if len(dc.acct.feeCoin) == 0 { - c.log.Errorf("empty fee coin found for unpaid account") - continue + // c.log.Errorf("empty fee coin found for unpaid account") + continue // normal if this account is active with bonds } wg.Add(1) go func(dc *dexConnection) { @@ -6077,12 +6251,12 @@ func (c *Core) checkUnpaidFees(wallet *xcWallet) { }(dc) } wg.Wait() - atomic.StoreUint32(&feeLock, 0) } // reFee attempts to finish the fee payment process for a DEX. reFee might be // called if the client was shutdown after a fee was paid, but before it had the // requisite confirmations for the 'notifyfee' message to be sent to the server. +// (V0PURGE) func (c *Core) reFee(wallet *xcWallet, dc *dexConnection) { feeAsset := dc.feeAsset(wallet.AssetID) if feeAsset == nil { @@ -6103,27 +6277,27 @@ func (c *Core) reFee(wallet *xcWallet, dc *dexConnection) { return } // A few sanity checks. - if !bytes.Equal(acctInfo.FeeCoin, dc.acct.feeCoin) { - c.log.Errorf("reFee %s - fee coin mismatch. %x != %x", dc.acct.host, acctInfo.FeeCoin, dc.acct.feeCoin) + if !bytes.Equal(acctInfo.LegacyFeeCoin, dc.acct.feeCoin) { + c.log.Errorf("reFee %s - fee coin mismatch. %x != %x", dc.acct.host, acctInfo.LegacyFeeCoin, dc.acct.feeCoin) return } - if acctInfo.FeeAssetID != dc.acct.feeAssetID { - c.log.Errorf("reFee %s - fee asset mismatch. %d != %d", dc.acct.host, acctInfo.FeeAssetID, dc.acct.feeAssetID) + if acctInfo.LegacyFeeAssetID != dc.acct.feeAssetID { + c.log.Errorf("reFee %s - fee asset mismatch. %d != %d", dc.acct.host, acctInfo.LegacyFeeAssetID, dc.acct.feeAssetID) return } - if acctInfo.Paid { + if acctInfo.LegacyFeePaid { c.log.Errorf("reFee %s - account for %x already marked paid", dc.acct.host, dc.acct.feeCoin) return } // Get the coin for the fee. - confs, err := wallet.RegFeeConfirmations(c.ctx, acctInfo.FeeCoin) + confs, err := wallet.RegFeeConfirmations(c.ctx, acctInfo.LegacyFeeCoin) if err != nil { c.log.Errorf("reFee %s - error getting coin confirmations: %v", dc.acct.host, err) return } if confs >= reqConfs { - err := c.notifyFee(dc, acctInfo.FeeCoin) + err := c.notifyFee(dc, acctInfo.LegacyFeeCoin) if err != nil { c.log.Errorf("reFee %s - notifyfee error: %v", dc.acct.host, err) subject, details := c.formatDetails(TopicFeePaymentError, dc.acct.host, err) @@ -6141,7 +6315,7 @@ func (c *Core) reFee(wallet *xcWallet, dc *dexConnection) { } return } - c.verifyRegistrationFee(wallet.AssetID, dc, acctInfo.FeeCoin, confs, reqConfs) + c.verifyRegistrationFee(wallet.AssetID, dc, acctInfo.LegacyFeeCoin, confs, reqConfs) } func (c *Core) dbOrders(host string) ([]*db.MetaOrder, error) { @@ -7079,16 +7253,58 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo, temporary ...bool) (*dexConn return dc, err } + // Categorize bonds now for sake of expired bonds that need to be refunded. + categorizeBonds := func(lockTimeThresh int64) { + dc.acct.authMtx.Lock() + defer dc.acct.authMtx.Unlock() + + for _, dbBond := range acctInfo.Bonds { + if dbBond.Refunded { // maybe don't even load these, but it may be of use for record keeping + continue + } + + bondIDStr := coinIDString(dbBond.AssetID, dbBond.CoinID) + + if int64(dbBond.LockTime) <= lockTimeThresh { + c.log.Infof("Loaded expired bond %v. Refund tx: %x", bondIDStr, dbBond.RefundTx) + dc.acct.expiredBonds = append(dc.acct.expiredBonds, dbBond) + continue + } + + if dbBond.Confirmed { + // This bond has already been confirmed by the server. + c.log.Infof("Loaded active bond %v. BACKUP refund tx: %x", bondIDStr, dbBond.RefundTx) + dc.acct.bonds = append(dc.acct.bonds, dbBond) + continue + } + + // Server has not yet confirmed this bond. + c.log.Infof("Loaded pending bond %v. Refund tx: %x", bondIDStr, dbBond.RefundTx) + dc.acct.pendingBonds = append(dc.acct.pendingBonds, dbBond) + + // We need to start monitorBondConfs on login since postbond + // requires the account keys. + } + + // Now in authDEX, we must reconcile the above categorized bonds + // according to ConnectResult.Bonds slice. + } + // Request the market configuration. - _, err = dc.refreshServerConfig() // handleReconnect must too + cfg, err := dc.refreshServerConfig() // handleReconnect must too if err != nil { + // Sort out the bonds with current time indicating refundable bonds. + categorizeBonds(time.Now().Unix()) if errors.Is(err, outdatedClientErr) { sendOutdatedClientNotification(c, dc) } - return dc, err + return dc, err // no dc.acct.dexPubKey } // handleConnectEvent sets dc.connected, even on first connect + // Given bond config, sort through our db.Bond slice. + categorizeBonds(time.Now().Unix() + int64(cfg.BondExpiry)) + if listen { c.log.Infof("Connected to DEX server at %s and listening for messages.", host) go dc.subPriceFeed() @@ -7162,7 +7378,8 @@ func (c *Core) handleReconnect(host string) { go dc.subPriceFeed() - if !dc.acct.locked() && dc.acct.feePaid() { + // If we are registered with this DEX, authenticate. + if !dc.acct.locked() /* && dc.acct.feePaid() */ { err = c.authDEX(dc) if err != nil { c.log.Errorf("handleReconnect: Unable to authorize DEX at %s: %v", host, err) @@ -7431,13 +7648,60 @@ func handlePenaltyMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error { return newError(signatureErr, "handlePenaltyMsg: DEX signature validation error: %w", err) } t := time.UnixMilli(int64(note.Penalty.Time)) - // d := time.Duration(note.Penalty.Duration) * time.Millisecond subject, details := c.formatDetails(TopicPenalized, dc.acct.host, note.Penalty.Rule, t, note.Penalty.Details) c.notify(newServerNotifyNote(TopicPenalized, subject, details, db.WarningLevel)) return nil } +func handleTierChangeMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error { + var tierChanged *msgjson.TierChangedNotification + err := msg.Unmarshal(&tierChanged) + if err != nil { + return fmt.Errorf("tier changed note unmarshal error: %w", err) + } + if tierChanged == nil { + return errors.New("empty message") + } + // Check the signature. + err = dc.acct.checkSig(tierChanged.Serialize(), tierChanged.Sig) + if err != nil { + return newError(signatureErr, "handleTierChangeMsg: DEX signature validation error: %v", err) // warn? + } + dc.acct.authMtx.Lock() + dc.acct.tier = tierChanged.Tier + dc.acct.authMtx.Unlock() + c.log.Infof("Received tierchanged notification from %v for account %v. New tier = %v", + dc.acct.host, dc.acct.ID(), tierChanged.Tier) + // TODO: notify sub consumers e.g. frontend + return nil +} + +func handleBondExpiredMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error { + var bondExpired *msgjson.BondExpiredNotification + err := msg.Unmarshal(&bondExpired) + if err != nil { + return fmt.Errorf("bond expired note unmarshal error: %w", err) + } + if bondExpired == nil { + return errors.New("empty message") + } + // Check the signature. + err = dc.acct.checkSig(bondExpired.Serialize(), bondExpired.Sig) + if err != nil { + return newError(signatureErr, "handleBondExpiredMsg: DEX signature validation error: %v", err) // warn? + } + + acctID := dc.acct.ID() + if !bytes.Equal(bondExpired.AccountID, acctID[:]) { + return fmt.Errorf("invalid account ID %v, expected %v", bondExpired.AccountID, acctID) + } + + c.log.Infof("Received bondexpired notification from %v for account %v...", dc.acct.host, acctID) + + return c.bondExpired(dc, bondExpired.AssetID, bondExpired.BondCoinID, bondExpired.Tier) +} + // routeHandler is a handler for a message from the DEX. type routeHandler func(*Core, *dexConnection, *msgjson.Message) error @@ -7463,6 +7727,8 @@ var noteHandlers = map[string]routeHandler{ msgjson.NoMatchRoute: handleNoMatchRoute, msgjson.RevokeOrderRoute: handleRevokeOrderMsg, msgjson.RevokeMatchRoute: handleRevokeMatchMsg, + msgjson.TierChangeRoute: handleTierChangeMsg, + msgjson.BondExpiredRoute: handleBondExpiredMsg, } // listen monitors the DEX websocket connection for server requests and @@ -8158,7 +8424,7 @@ func (c *Core) tipChange(assetID uint32, nodeErr error) { c.log.Errorf("%s wallet is reporting a failed state: %v", unbip(assetID), nodeErr) return } - c.log.Tracef("processing tip change for %s", unbip(assetID)) + c.log.Tracef("Processing tip change for %s", unbip(assetID)) c.waiterMtx.Lock() for id, waiter := range c.blockWaiters { if waiter.assetID != assetID { @@ -8169,6 +8435,7 @@ func (c *Core) tipChange(assetID uint32, nodeErr error) { if err != nil { waiter.action(err) c.removeWaiter(id) + return } if ok { waiter.action(nil) @@ -8261,11 +8528,11 @@ func sendRequest(conn comms.WsConn, route string, request, response interface{}, err = conn.RequestWithTimeout(reqMsg, func(msg *msgjson.Message) { errChan <- msg.UnmarshalResult(response) }, timeout, func() { - errChan <- fmt.Errorf("timed out waiting for %q response", route) + errChan <- fmt.Errorf("timed out waiting for %q response", route) // code this as a timeout! }) // Check the request error. if err != nil { - return err + return err // code this as a send error! } // Check the response error. diff --git a/client/core/core_test.go b/client/core/core_test.go index fd37388153..6c4130ac27 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -335,7 +335,7 @@ func (conn *TWebsocket) RequestWithTimeout(msg *msgjson.Message, f func(*msgjson } return fmt.Errorf("no handler for route %q", msg.Route) } -func (conn *TWebsocket) MessageSource() <-chan *msgjson.Message { return conn.msgs } +func (conn *TWebsocket) MessageSource() <-chan *msgjson.Message { return conn.msgs } // use when Core.listen is running func (conn *TWebsocket) IsDown() bool { return false } @@ -351,7 +351,8 @@ type TDB struct { acct *db.AccountInfo acctErr error createAccountErr error - accountPaidErr error + addBondErr error + storeAccountProofErr error updateOrderErr error activeDEXOrders []*db.MetaOrder matchesForOID []*db.MetaMatch @@ -374,7 +375,6 @@ type TDB struct { verifyAccountPaid bool verifyCreateAccount bool verifyUpdateAccountInfo bool - accountProofPersisted *db.AccountProof disabledHost *string disableAccountErr error creds *db.PrimaryCredentials @@ -408,6 +408,21 @@ func (tdb *TDB) CreateAccount(ai *db.AccountInfo) error { return tdb.createAccountErr } +func (tdb *TDB) NextBondKeyIndex(assetID uint32) (uint32, error) { + return 0, nil +} + +func (tdb *TDB) AddBond(host string, bond *db.Bond) error { + return tdb.addBondErr +} + +func (tdb *TDB) ConfirmBond(host string, assetID uint32, bondCoinID []byte) error { + return nil +} +func (tdb *TDB) BondRefunded(host string, assetID uint32, bondCoinID []byte) error { + return nil +} + func (tdb *TDB) DisableAccount(url string) error { tdb.disabledHost = &url return tdb.disableAccountErr @@ -514,10 +529,10 @@ func (tdb *TDB) AccountProof(url string) (*db.AccountProof, error) { return tdb.accountProof, tdb.accountProofErr } -func (tdb *TDB) AccountPaid(proof *db.AccountProof) error { +func (tdb *TDB) StoreAccountProof(proof *db.AccountProof) error { tdb.verifyAccountPaid = true - tdb.accountProofPersisted = proof - return tdb.accountPaidErr + // todo: save proof? + return tdb.storeAccountProofErr } func (tdb *TDB) SaveNotification(*db.Notification) error { return nil } @@ -1849,6 +1864,42 @@ func TestCreateWallet(t *testing.T) { } } +// TODO: TestGetDEXConfig +/* +func TestGetFee(t *testing.T) { + rig := newTestRig() + defer rig.shutdown() + tCore := rig.core + cert := []byte{} + + // DEX already registered + _, err := tCore.GetFee(tDexHost, cert) + if !errorHasCode(err, dupeDEXErr) { + t.Fatalf("wrong account exists error: %v", err) + } + + // Lose the dexConnection + tCore.connMtx.Lock() + delete(tCore.conns, tDexHost) + tCore.connMtx.Unlock() + + // connectDEX error + _, err = tCore.GetFee(tUnparseableHost, cert) + if !errorHasCode(err, addressParseErr) { + t.Fatalf("wrong connectDEX error: %v", err) + } + + // Queue a config response for success + rig.queueConfig() + + // Success + _, err = tCore.GetFee(tDexHost, cert) + if err != nil { + t.Fatalf("GetFee error: %v", err) + } +} +*/ + func TestRegister(t *testing.T) { // This test takes a little longer because the key is decrypted every time // Register is called. @@ -1901,7 +1952,7 @@ func TestRegister(t *testing.T) { tCore.waiterMtx.Unlock() if waiterCount > 0 { // when verifyRegistrationFee adds a waiter, then we can trigger tip change timeout.Stop() - tWallet.setConfs(tWallet.sendCoin.id, 0, nil) + tWallet.setConfs(tWallet.sendCoin.id, 0, nil) // 0 ???? tCore.tipChange(tUTXOAssetA.ID, nil) return } @@ -2273,6 +2324,12 @@ func TestCredentialsUpgrade(t *testing.T) { } } +func unauth(a *dexAccount) { + a.authMtx.Lock() + a.isAuthed = false + a.authMtx.Unlock() +} + func TestLogin(t *testing.T) { rig := newTestRig() defer rig.shutdown() @@ -2286,7 +2343,7 @@ func TestLogin(t *testing.T) { } // No encryption key. - rig.acct.unauth() + unauth(rig.acct) creds := tCore.credentials tCore.credentials = nil err = tCore.Login(tPW) @@ -2311,7 +2368,7 @@ func TestLogin(t *testing.T) { rig = newTestRig() defer rig.shutdown() tCore = rig.core - rig.acct.unauth() + unauth(rig.acct) rig.ws.queueResponse(msgjson.ConnectRoute, func(msg *msgjson.Message, f msgFunc) error { resp, _ := msgjson.NewResponse(msg.ID, nil, msgjson.NewError(1, "test error")) f(resp) diff --git a/client/core/errors.go b/client/core/errors.go index 8e6543eb52..21d5c29c54 100644 --- a/client/core/errors.go +++ b/client/core/errors.go @@ -48,6 +48,9 @@ const ( createWalletErr activeOrdersErr newAddrErr + bondAmtErr + bondTimeErr + bondPostErr // TODO ) // Error is an error code and a wrapped error. diff --git a/client/core/notification.go b/client/core/notification.go index 9debeb10ce..387d9cb775 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -16,6 +16,7 @@ import ( // Notifications should use the following note type strings. const ( NoteTypeFeePayment = "feepayment" + NoteTypeBondPost = "bondpost" NoteTypeSend = "send" NoteTypeOrder = "order" NoteTypeMatch = "match" @@ -187,27 +188,30 @@ func newSecurityNote(topic Topic, subject, details string, severity db.Severity) } } -// FeePaymentNote is a notification regarding registration fee payment. -type FeePaymentNote struct { - db.Notification - Asset *uint32 `json:"asset,omitempty"` - Confirmations *uint32 `json:"confirmations,omitempty"` - Dex string `json:"dex,omitempty"` -} - const ( TopicFeePaymentInProgress Topic = "FeePaymentInProgress" - TopicRegUpdate Topic = "RegUpdate" TopicFeePaymentError Topic = "FeePaymentError" + TopicFeeCoinError Topic = "FeeCoinError" + TopicRegUpdate Topic = "RegUpdate" + TopicBondConfirming Topic = "BondConfirming" + TopicBondPostError Topic = "BondPostError" + TopicBondCoinError Topic = "BondCoinError" TopicAccountRegistered Topic = "AccountRegistered" TopicAccountUnlockError Topic = "AccountUnlockError" - TopicFeeCoinError Topic = "FeeCoinError" TopicWalletConnectionWarning Topic = "WalletConnectionWarning" TopicWalletUnlockError Topic = "WalletUnlockError" TopicWalletCommsWarning Topic = "WalletCommsWarning" TopicWalletPeersRestored Topic = "WalletPeersRestored" ) +// FeePaymentNote is a notification regarding registration fee payment. +type FeePaymentNote struct { + db.Notification + Asset *uint32 `json:"asset,omitempty"` + Confirmations *uint32 `json:"confirmations,omitempty"` + Dex string `json:"dex,omitempty"` +} + func newFeePaymentNote(topic Topic, subject, details string, severity db.Severity, dexAddr string) *FeePaymentNote { host, _ := addrHost(dexAddr) return &FeePaymentNote{ @@ -223,6 +227,36 @@ func newFeePaymentNoteWithConfirmations(topic Topic, subject, details string, se return feePmtNt } +// BondPostNote is a notification regarding bond posting. +type BondPostNote struct { + db.Notification + Asset *uint32 `json:"asset,omitempty"` + Confirmations *int32 `json:"confirmations,omitempty"` + Tier *int64 `json:"tier,omitempty"` + Dex string `json:"dex,omitempty"` +} + +func newBondPostNote(topic Topic, subject, details string, severity db.Severity, dexAddr string) *BondPostNote { + host, _ := addrHost(dexAddr) + return &BondPostNote{ + Notification: db.NewNotification(NoteTypeBondPost, topic, subject, details, severity), + Dex: host, + } +} + +func newBondPostNoteWithConfirmations(topic Topic, subject, details string, severity db.Severity, asset uint32, currConfs int32, dexAddr string) *BondPostNote { + bondPmtNt := newBondPostNote(topic, subject, details, severity, dexAddr) + bondPmtNt.Asset = &asset + bondPmtNt.Confirmations = &currConfs + return bondPmtNt +} + +func newBondPostNoteWithTier(topic Topic, subject, details string, severity db.Severity, dexAddr string, tier int64) *BondPostNote { + bondPmtNt := newBondPostNote(topic, subject, details, severity, dexAddr) + bondPmtNt.Tier = &tier + return bondPmtNt +} + // SendNote is a notification regarding a requested send or withdraw. type SendNote struct { db.Notification @@ -456,6 +490,8 @@ const ( TopicDexAuthError Topic = "DexAuthError" TopicUnknownOrders Topic = "UnknownOrders" TopicOrdersReconciled Topic = "OrdersReconciled" + TopicBondConfirmed Topic = "BondConfirmed" + TopicBondExpired Topic = "BondExpired" ) func newDEXAuthNote(topic Topic, subject, host string, authenticated bool, details string, severity db.Severity) *DEXAuthNote { diff --git a/client/core/types.go b/client/core/types.go index a56f2e9a58..b995699229 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -28,10 +28,16 @@ import ( "github.com/decred/dcrd/hdkeychain/v3" ) -// HDKeyPurpose is here because we're high-jacking the BIP-32 + BIP-43 HD key -// system to create child keys, so we must set a the purpose field to create an -// hdkeychain.ExtendedKey. -var HDKeyPurpose uint32 = hdkeychain.HardenedKeyStart + 0x646578 // ASCII "dex" +const ( + // hdKeyPurposeAccts is the BIP-43 purpose field for DEX account keys. + hdKeyPurposeAccts uint32 = hdkeychain.HardenedKeyStart + 0x646578 // ASCII "dex" + // hdKeyPurposeBonds is the BIP-43 purpose field for bond keys. These keys + // are separate from DEX accounts. The following path that is independent of + // a dex account will simplify discovery and key recovery if we devise a + // scheme to locate them on-chain: + // m / hdKeyPurposeBonds / assetID' / bondIndex + hdKeyPurposeBonds uint32 = hdkeychain.HardenedKeyStart + 0x626f6e64 // ASCII "bond" +) // errorSet is a slice of orders with a prefix prepended to the Error output. type errorSet struct { @@ -150,7 +156,30 @@ type SupportedAsset struct { WalletCreationPending bool `json:"walletCreationPending"` } -// RegisterForm is information necessary to register an account on a DEX. +// BondOptionsForm is used from the settings page to change the auto-bond +// maintenance setting for a DEX. +// type BondOptionsForm struct { +// MaintainBonds bool `json:"maintainbonds"` // auto-post new bonds when old ones expire +// } + +// PostBondForm is information necessary to post a new bond for a new or +// existing DEX account at the specified DEX address. +type PostBondForm struct { + Addr string `json:"host"` + AppPass encode.PassBytes `json:"appPass"` + Asset *uint32 `json:"assetID,omitempty"` // do not default to 0 + Bond uint64 `json:"bond"` + LockTime uint64 `json:"lockTime"` // 0 means go with server-derived value + // BondOptionsForm, maybe + + // Cert is needed if posting bond to a new DEX. Cert can be a string, which + // is interpreted as a filepath, or a []byte, which is interpreted as the + // file contents of the certificate. + Cert interface{} `json:"cert"` +} + +// RegisterForm is information necessary to register an account on a DEX. Old +// registration fee (V0PURGE) type RegisterForm struct { Addr string `json:"url"` AppPass encode.PassBytes `json:"appPass"` @@ -669,13 +698,13 @@ type dexAccount struct { privKey *secp256k1.PrivateKey id account.AccountID - authMtx sync.RWMutex - isAuthed bool - // pendingBonds []*db.Bond // not yet confirmed - // bonds []*db.Bond // confirmed, and not yet expired - // expiredBonds []*db.Bond // expired and needing refund - tier int64 // check instead of isSuspended - legacyFeePaid bool // server reports a legacy fee paid + authMtx sync.RWMutex + isAuthed bool + pendingBonds []*db.Bond // not yet confirmed + bonds []*db.Bond // confirmed, and not yet expired + expiredBonds []*db.Bond // expired and needing refund + tier int64 // check instead of isSuspended + legacyFeePaid bool // server reports a legacy fee paid // Legacy reg fee (V0PURGE) feeAssetID uint32 @@ -692,9 +721,9 @@ func newDEXAccount(acctInfo *db.AccountInfo) *dexAccount { cert: acctInfo.Cert, dexPubKey: acctInfo.DEXPubKey, encKey: acctInfo.EncKey(), // privKey and id on decrypt - feeAssetID: acctInfo.FeeAssetID, - feeCoin: acctInfo.FeeCoin, - isPaid: acctInfo.Paid, + feeAssetID: acctInfo.LegacyFeeAssetID, + feeCoin: acctInfo.LegacyFeeCoin, + isPaid: acctInfo.LegacyFeePaid, // bonds are set separately when categorized in authDEX } } @@ -734,7 +763,7 @@ func (a *dexAccount) setupCryptoV2(creds *db.PrimaryCredentials, crypter encrypt // Prepare the chain of child indices. kids := make([]uint32, 0, 11) // 1 x purpose, 1 x version (incl. oddness), 8 x 4-byte uint32s, 1 x acct key index. // Hardened "purpose" key. - kids = append(kids, HDKeyPurpose) + kids = append(kids, hdKeyPurposeAccts) // Second child is the the format/oddness byte. kids = append(kids, uint32(dexPkB[0])) byteSeq := dexPkB[1:] @@ -807,6 +836,15 @@ func (a *dexAccount) locked() bool { return a.privKey == nil } +func (a *dexAccount) pubKey() []byte { + a.keyMtx.RLock() + defer a.keyMtx.RUnlock() + if a.privKey == nil { + return nil + } + return a.privKey.PubKey().SerializeCompressed() +} + // authed will be true if the account has been authenticated i.e. the 'connect' // request has been successfully sent. func (a *dexAccount) authed() bool { @@ -824,13 +862,6 @@ func (a *dexAccount) auth(tier int64, legacyFeePaid bool) { a.authMtx.Unlock() } -// unauth sets the account as un-authenticated. -func (a *dexAccount) unauth() { - a.authMtx.Lock() - a.isAuthed = false - a.authMtx.Unlock() -} - // suspended will be true if the account was suspended as of the latest authDEX. func (a *dexAccount) suspended() bool { a.authMtx.RLock() @@ -838,12 +869,6 @@ func (a *dexAccount) suspended() bool { return a.tier < 1 } -func (a *dexAccount) hasLegacyFee() bool { - a.authMtx.RLock() - defer a.authMtx.RUnlock() - return len(a.feeCoin) > 0 -} - // feePending checks whether the fee transaction has been broadcast, but the // notifyfee request has not been sent/accepted yet. func (a *dexAccount) feePending() bool { @@ -931,11 +956,17 @@ func coinIDString(assetID uint32, coinID []byte) string { } // RegisterResult holds data returned from Register. -type RegisterResult struct { +type RegisterResult struct { // V0PURGE FeeID string `json:"feeID"` ReqConfirms uint16 `json:"reqConfirms"` } +// PostBondResult holds the data returned from PostBond. +type PostBondResult struct { + BondID string `json:"bondID"` + ReqConfirms uint16 `json:"reqConfirms"` +} + // OrderFilter is almost the same as db.OrderFilter, except the Offset order ID // is a dex.Bytes instead of a order.OrderID. type OrderFilter struct { @@ -953,9 +984,9 @@ type Account struct { PrivKey string `json:"privKey"` DEXPubKey string `json:"DEXPubKey"` Cert string `json:"cert"` - FeeCoin string `json:"feeCoin"` - FeeProofSig string `json:"feeProofSig"` - FeeProofStamp uint64 `json:"FeeProofStamp"` + FeeCoin string `json:"feeCoin,omitempty"` // DEPRECATED, remains for old accounts + FeeProofSig string `json:"feeProofSig,omitempty"` // DEPRECATED + FeeProofStamp uint64 `json:"feeProofStamp,omitempty"` // DEPRECATED } // assetMap tracks a series of assets and provides methods for registering an diff --git a/client/core/wallet.go b/client/core/wallet.go index dbf09a905f..05dd56b041 100644 --- a/client/core/wallet.go +++ b/client/core/wallet.go @@ -15,6 +15,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/encrypt" + "github.com/decred/dcrd/dcrec/secp256k1/v4" ) var errWalletNotConnected = errors.New("wallet not connected") @@ -474,6 +475,35 @@ func (w *xcWallet) swapConfirmations(ctx context.Context, coinID []byte, contrac return w.Wallet.SwapConfirmations(ctx, coinID, contract, time.UnixMilli(int64(matchTime))) } +// MakeBondTx authors a DEX time-locked fidelity bond transaction if the +// asset.Wallet implementation is a Bonder. +func (w *xcWallet) MakeBondTx(ver uint16, amt uint64, lockTime time.Time, priv *secp256k1.PrivateKey, acctID []byte) (*asset.Bond, error) { + bonder, ok := w.Wallet.(asset.Bonder) + if !ok { + return nil, errors.New("wallet does not support making bond transactions") + } + return bonder.MakeBondTx(ver, amt, lockTime, priv, acctID) +} + +// RefundBond will refund the bond if the asset.Wallet implementation is a +// Bonder. The lock time must be passed to spend the bond. LockTimeExpired +// should be used to check first. +func (w *xcWallet) RefundBond(ctx context.Context, ver uint16, coinID, script []byte, amt uint64, priv *secp256k1.PrivateKey) ([]byte, error) { + bonder, ok := w.Wallet.(asset.Bonder) + if !ok { + return nil, errors.New("wallet does not support refunding bond transactions") + } + return bonder.RefundBond(ctx, ver, coinID, script, amt, priv) +} + +func (w *xcWallet) SendTransaction(tx []byte) ([]byte, error) { + bonder, ok := w.Wallet.(asset.Bonder) + if !ok { + return nil, errors.New("wallet does not implement SendTransaction") // seems silly, I know, but Bonder is optional + } + return bonder.SendTransaction(tx) +} + // feeRater is identical to calling w.Wallet.(asset.FeeRater). func (w *xcWallet) feeRater() (asset.FeeRater, bool) { rater, is := w.Wallet.(asset.FeeRater) From 1baf6f9ebab8096abd65ac63bc14554c469f38bc Mon Sep 17 00:00:00 2001 From: Jonathan Chappelow Date: Mon, 14 Nov 2022 17:15:24 -0600 Subject: [PATCH 3/5] client/asset: tweak bond data field name --- client/asset/dcr/dcr.go | 12 ++++++------ client/asset/dcr/simnet_test.go | 6 +++--- client/asset/interface.go | 12 +++++++++--- client/core/wallet.go | 5 +++-- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 630fe3e7dc..700032206a 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -3202,11 +3202,11 @@ func (dcr *ExchangeWallet) EstimateRegistrationTxFee(feeRate uint64) uint64 { // Output 2 is change, if any. // // The bond output's redeem script, which is needed to spend the bond output, is -// returned as the BondData field of the Bond. The bond output pays to a -// pubkeyhash script for a wallet address. Bond.RedeemTx is a backup transaction -// that spends the bond output after lockTime passes, paying to an address for -// the current underlying wallet; the bond private key (BondPrivKey) should -// normally be used to author a new transaction paying to a new address instead. +// returned as the Data field of the Bond. The bond output pays to a pubkeyhash +// script for a wallet address. Bond.RedeemTx is a backup transaction that +// spends the bond output after lockTime passes, paying to an address for the +// current underlying wallet; the bond private key (BondPrivKey) should normally +// be used to author a new transaction paying to a new address instead. func (dcr *ExchangeWallet) MakeBondTx(ver uint16, amt uint64, lockTime time.Time, bondKey *secp256k1.PrivateKey, acctID []byte) (*asset.Bond, error) { if ver != 0 { @@ -3320,7 +3320,7 @@ func (dcr *ExchangeWallet) MakeBondTx(ver uint16, amt uint64, lockTime time.Time AssetID: BipID, Amount: amt, CoinID: toCoinID(&txid, 0), - BondData: bondScript, + Data: bondScript, BondPrivKey: bondKey.Serialize(), SignedTx: signedTxBytes, UnsignedTx: unsignedTxBytes, diff --git a/client/asset/dcr/simnet_test.go b/client/asset/dcr/simnet_test.go index de3d73bf89..19fe59eb64 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -189,7 +189,7 @@ func TestMakeBondTx(t *testing.T) { t.Logf("bond txid %v\n", coinhash) t.Logf("signed tx: %x\n", bond.SignedTx) t.Logf("unsigned tx: %x\n", bond.UnsignedTx) - t.Logf("bond script: %x\n", bond.BondData) + t.Logf("bond script: %x\n", bond.Data) t.Logf("redeem tx: %x\n", bond.RedeemTx) bondMsgTx, err := msgTxFromBytes(bond.SignedTx) if err != nil { @@ -199,7 +199,7 @@ func TestMakeBondTx(t *testing.T) { pkh := dcrutil.Hash160(pubkey.SerializeCompressed()) - lockTimeUint, pkhPush, err := dexdcr.ExtractBondDetailsV0(bondOutVersion, bond.BondData) + lockTimeUint, pkhPush, err := dexdcr.ExtractBondDetailsV0(bondOutVersion, bond.Data) if err != nil { t.Fatalf("ExtractBondDetailsV0: %v", err) } @@ -238,7 +238,7 @@ func TestMakeBondTx(t *testing.T) { waitNetwork() // wait for beta to see the new block (bond must be mined for RefundBond) refundTxNew, err := wallet.RefundBond(context.Background(), bondVer, bond.CoinID, - bond.BondData, bond.Amount, priv) + bond.Data, bond.Amount, priv) if err != nil { t.Fatalf("RefundBond: %v", err) } diff --git a/client/asset/interface.go b/client/asset/interface.go index a5b53368a4..8dc96fefec 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -458,9 +458,17 @@ type TxFeeEstimator interface { EstimateSendTxFee(address string, value, feeRate uint64, subtract bool) (fee uint64, isValidAddress bool, err error) } +// Broadcaster is a wallet that can send a raw transaction on the asset network. +type Broadcaster interface { + // SendTransaction broadcasts a raw transaction, returning its coin ID. + SendTransaction(rawTx []byte) ([]byte, error) +} + // Bonder is a wallet capable of creating and redeeming time-locked fidelity // bond transaction outputs. type Bonder interface { + Broadcaster + // MakeBondTx authors a DEX time-locked fidelity bond transaction for the // provided amount, lock time, and dex account ID. An explicit private key // type is used to guarantee it's not bytes from something else like a @@ -469,8 +477,6 @@ type Bonder interface { // RefundBond will refund the bond given the full bond output details and // private key to spend it. RefundBond(ctx context.Context, ver uint16, coinID, script []byte, amt uint64, privKey *secp256k1.PrivateKey) ([]byte, error) - // SendTransaction broadcasts a raw transaction, returning its coin ID. - SendTransaction(rawTx []byte) ([]byte, 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 @@ -743,7 +749,7 @@ type Bond struct { AssetID uint32 Amount uint64 CoinID []byte - BondData []byte // additional data to interpret the bond e.g. redeem script, bond contract, etc. + Data []byte // additional data to interpret the bond e.g. redeem script, bond contract, etc. BondPrivKey []byte // caller provided, but kept with the output // SignedTx and UnsignedTx are the opaque (raw bytes) signed and unsigned // bond creation transactions, in whatever encoding and funding scheme for diff --git a/client/core/wallet.go b/client/core/wallet.go index 05dd56b041..024de7cb9a 100644 --- a/client/core/wallet.go +++ b/client/core/wallet.go @@ -496,10 +496,11 @@ func (w *xcWallet) RefundBond(ctx context.Context, ver uint16, coinID, script [] return bonder.RefundBond(ctx, ver, coinID, script, amt, priv) } +// SendTransaction broadcasts a raw transaction if the wallet is a Broadcaster. func (w *xcWallet) SendTransaction(tx []byte) ([]byte, error) { - bonder, ok := w.Wallet.(asset.Bonder) + bonder, ok := w.Wallet.(asset.Broadcaster) if !ok { - return nil, errors.New("wallet does not implement SendTransaction") // seems silly, I know, but Bonder is optional + return nil, errors.New("wallet is not a Broadcaster") } return bonder.SendTransaction(tx) } From 4a01108390504cd6902cec66fd8a1cbc05f0d815 Mon Sep 17 00:00:00 2001 From: Jonathan Chappelow Date: Fri, 2 Jul 2021 18:00:36 -0500 Subject: [PATCH 4/5] client/rpcserver: bondassets and addbond, update register --- client/cmd/dexcctl/main.go | 3 + client/rpcserver/handlers.go | 116 ++++++++++++++++++++++++++++- client/rpcserver/rpcserver.go | 3 +- client/rpcserver/rpcserver_test.go | 5 ++ client/rpcserver/types.go | 57 +++++++++++++- client/rpcserver/types_test.go | 2 + dex/msgjson/types.go | 1 + 7 files changed, 183 insertions(+), 4 deletions(-) diff --git a/client/cmd/dexcctl/main.go b/client/cmd/dexcctl/main.go index 8f8c528fcc..54f8354e7a 100644 --- a/client/cmd/dexcctl/main.go +++ b/client/cmd/dexcctl/main.go @@ -54,6 +54,7 @@ var promptPasswords = map[string][]string{ "newwallet": {"App password:", "Wallet password:"}, "openwallet": {"App password:"}, "register": {"App password:"}, + "postbond": {"App password:"}, "trade": {"App password:"}, "withdraw": {"App password:"}, "send": {"App password:"}, @@ -65,6 +66,8 @@ var promptPasswords = map[string][]string{ // cmd args at the specified index. var optionalTextFiles = map[string]int{ "discoveracct": 1, + "bondassets": 1, + "postbond": 4, "getdexconfig": 1, "register": 3, "newwallet": 2, diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index c3ba0ddeca..2da84ef5ce 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -32,8 +32,10 @@ const ( openWalletRoute = "openwallet" toggleWalletStatusRoute = "togglewalletstatus" orderBookRoute = "orderbook" - getDEXConfRoute = "getdexconfig" // consider a getfees route + getDEXConfRoute = "getdexconfig" + bondAssetsRoute = "bondassets" registerRoute = "register" + postBondRoute = "postbond" tradeRoute = "trade" versionRoute = "version" walletsRoute = "wallets" @@ -93,6 +95,8 @@ var routes = map[string]func(s *RPCServer, params *RawParams) *msgjson.ResponseP orderBookRoute: handleOrderBook, getDEXConfRoute: handleGetDEXConfig, registerRoute: handleRegister, + postBondRoute: handlePostBond, + bondAssetsRoute: handleBondAssets, tradeRoute: handleTrade, versionRoute: handleVersion, walletsRoute: handleWallets, @@ -283,6 +287,30 @@ func handleWallets(s *RPCServer, _ *RawParams) *msgjson.ResponsePayload { return createResponse(walletsRoute, walletsStates, nil) } +// handleBondAssets handles requests for bondassets. +// *msgjson.ResponsePayload.Error is empty if successful. Requires the address +// of a dex and returns the bond expiry and supported asset bond details. +func handleBondAssets(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + host, cert, err := parseBondAssetsArgs(params) + if err != nil { + return usage(bondAssetsRoute, err) + } + exchInf := s.core.Exchanges() + exchCfg := exchInf[host] + if exchCfg == nil { + exchCfg, err = s.core.GetDEXConfig(host, cert) // cert is file contents, not name + if err != nil { + resErr := msgjson.NewError(msgjson.RPCGetDEXConfigError, err.Error()) + return createResponse(bondAssetsRoute, nil, resErr) + } + } + res := &getBondAssetsResponse{ + Expiry: exchCfg.BondExpiry, + Assets: exchCfg.BondAssets, + } + return createResponse(bondAssetsRoute, res, nil) +} + // handleGetDEXConfig handles requests for getdexconfig. // *msgjson.ResponsePayload.Error is empty if successful. Requires the address // of a dex and returns its config.. @@ -316,7 +344,7 @@ func handleDiscoverAcct(s *RPCServer, params *RawParams) *msgjson.ResponsePayloa } // handleRegister handles requests for register. *msgjson.ResponsePayload.Error -// is empty if successful. +// is empty if successful. DEPRECATED BY postbond. (V0PURGE) func handleRegister(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { form, err := parseRegisterArgs(params) if err != nil { @@ -354,6 +382,54 @@ func handleRegister(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { return createResponse(registerRoute, res, nil) } +// handlePostBond handles requests for postbond. *msgjson.ResponsePayload.Error +// is empty if successful. +func handlePostBond(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + form, err := parsePostBondArgs(params) + if err != nil { + return usage(postBondRoute, err) + } + defer form.AppPass.Clear() + // Get the exchange config with Exchanges(), not GetDEXConfig, since we may + // already be connected and even with an existing account. + exchInf := s.core.Exchanges() + exchCfg := exchInf[form.Addr] + if exchCfg == nil { + // Not already registered. + exchCfg, err = s.core.GetDEXConfig(form.Addr, form.Cert) + if err != nil { + resErr := &msgjson.Error{Code: msgjson.RPCGetDEXConfigError, Message: err.Error()} + return createResponse(registerRoute, nil, resErr) + } + } + // Registration with different assets will be supported in the future, but + // for now, this requires DCR. + assetID := uint32(42) + if form.Asset != nil { + assetID = *form.Asset + } + symb := dex.BipIDSymbol(assetID) + + bondAsset, supported := exchCfg.BondAssets[symb] + if !supported { + errMsg := fmt.Sprintf("DEX %s does not support registration with %s", form.Addr, symb) + resErr := msgjson.NewError(msgjson.RPCPostBondError, errMsg) + return createResponse(postBondRoute, nil, resErr) + } + if bondAsset.Amt > form.Bond || form.Bond%bondAsset.Amt != 0 { + errMsg := fmt.Sprintf("DEX at %s expects a bond amount in multiples of %d %s but %d was offered", + form.Addr, bondAsset.Amt, dex.BipIDSymbol(assetID), form.Bond) + resErr := msgjson.NewError(msgjson.RPCPostBondError, errMsg) + return createResponse(postBondRoute, nil, resErr) + } + res, err := s.core.PostBond(form) + if err != nil { + resErr := &msgjson.Error{Code: msgjson.RPCPostBondError, Message: err.Error()} + return createResponse(postBondRoute, nil, resErr) + } + return createResponse(postBondRoute, res, nil) +} + // handleExchanges handles requests for exchanges. It takes no arguments and // returns a map of exchanges. func handleExchanges(s *RPCServer, _ *RawParams) *msgjson.ResponsePayload { @@ -899,6 +975,23 @@ var helpMsgs = map[string]helpMsg{ Will not save by default.`, returns: `Returns: Nothing.`, + }, + bondAssetsRoute: { + argsShort: `"dex" ("cert")`, + cmdSummary: `Get dex bond asset config.`, + argsLong: `Args: + dex (string): The dex address to get bond info for. + cert (string): Optional. The TLS certificate path.`, + returns: `Returns: + obj: The getBondAssets result. + { + "expiry" (int): Bond expiry in seconds remaining until locktime. + "assets" (object): { + "id" (int): The BIP-44 coin type for the asset. + "confs" (int): The required confirmations for the bond transaction. + "amount" (int): The minimum bond amount. + } + }`, }, getDEXConfRoute: { argsShort: `"dex" ("cert")`, @@ -1004,6 +1097,25 @@ Registration is complete after the fee transaction has been confirmed.`, "feeID" (string): The fee transactions's txid and output index. "reqConfirms" (int): The number of confirmations required to start trading. }`, + }, + postBondRoute: { + pwArgsShort: `"appPass"`, + argsShort: `"addr" bond assetID (lockTime "cert")`, + cmdSummary: `Post new bond for DEX. An ok response does not mean that the bond is active. + Bond is active after the bond transaction has been confirmed and the server notified.`, + pwArgsLong: `Password Args: + appPass (string): The DEX client password.`, + argsLong: `Args: + addr (string): The DEX address to post bond for for. + bond (int): The bond amount (in DCR presently). + assetID (int): The asset ID with which to pay the fee. + lockTime (int): The bond's lockTime as UNIX epoch time (seconds). + cert (string): Optional. The TLS certificate path.`, + returns: `Returns: + { + "bondID" (string): The bond transactions's txid and output index. + "reqConfirms" (int): The number of confirmations required to start trading. + }`, }, exchangesRoute: { cmdSummary: `Detailed information about known exchanges and markets.`, diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index 30246a279d..1e2ec28441 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -69,7 +69,8 @@ type clientCore interface { OpenWallet(assetID uint32, appPass []byte) error ToggleWalletStatus(assetID uint32, disable bool) error GetDEXConfig(dexAddr string, certI interface{}) (*core.Exchange, error) - Register(form *core.RegisterForm) (*core.RegisterResult, error) + Register(form *core.RegisterForm) (*core.RegisterResult, error) // V0PURGE + PostBond(form *core.PostBondForm) (*core.PostBondResult, error) Trade(appPass []byte, form *core.TradeForm) (order *core.Order, err error) Wallets() (walletsStates []*core.WalletState) WalletState(assetID uint32) *core.WalletState diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index 5e17dba15e..decd7722e8 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -48,6 +48,8 @@ type TCore struct { initializeClientErr error registerResult *core.RegisterResult registerErr error + postBondResult *core.PostBondResult + postBondErr error exchanges map[string]*core.Exchange loginErr error order *core.Order @@ -123,6 +125,9 @@ func (c *TCore) GetDEXConfig(dexAddr string, certI interface{}) (*core.Exchange, func (c *TCore) Register(*core.RegisterForm) (*core.RegisterResult, error) { return c.registerResult, c.registerErr } +func (c *TCore) PostBond(*core.PostBondForm) (*core.PostBondResult, error) { + return c.postBondResult, c.postBondErr +} func (c *TCore) SyncBook(dex string, base, quote uint32) (core.BookFeed, error) { return &tBookFeed{}, c.syncErr } diff --git a/client/rpcserver/types.go b/client/rpcserver/types.go index 7303c6be97..80e95568af 100644 --- a/client/rpcserver/types.go +++ b/client/rpcserver/types.go @@ -48,6 +48,12 @@ type SemVersion struct { BuildMetadata string `json:"buildMetadata,omitempty"` } +// getBondAssetsResponse is the getbondassets response payload. +type getBondAssetsResponse struct { + Expiry uint64 `json:"expiry"` + Assets map[string]*core.BondAsset `json:"assets"` +} + // tradeResponse is used when responding to the trade route. type tradeResponse struct { OrderID string `json:"orderID"` @@ -390,11 +396,13 @@ func parseRegisterArgs(params *RawParams) (*core.RegisterForm, error) { if err != nil { return nil, err } - asset32 := uint32(asset) + var cert []byte if len(params.Args) > 3 { cert = []byte(params.Args[3]) } + + asset32 := uint32(asset) req := &core.RegisterForm{ AppPass: params.PWArgs[0], Addr: params.Args[0], @@ -405,6 +413,53 @@ func parseRegisterArgs(params *RawParams) (*core.RegisterForm, error) { return req, nil } +func parseBondAssetsArgs(params *RawParams) (host string, cert []byte, err error) { + if err := checkNArgs(params, []int{0}, []int{1, 2}); err != nil { + return "", nil, err + } + if len(params.Args) == 1 { + return params.Args[0], nil, nil + } + return params.Args[0], []byte(params.Args[1]), nil +} + +func parsePostBondArgs(params *RawParams) (*core.PostBondForm, error) { + if err := checkNArgs(params, []int{1}, []int{3, 5}); err != nil { + return nil, err + } + bond, err := checkUIntArg(params.Args[1], "bond", 64) + if err != nil { + return nil, err + } + asset, err := checkUIntArg(params.Args[2], "asset", 32) + if err != nil { + return nil, err + } + + var lockTimeEpoch uint64 + if len(params.Args) > 3 { + lockTimeEpoch, err = checkUIntArg(params.Args[3], "locktime", 64) + if err != nil { + return nil, err + } + } + var cert []byte + if len(params.Args) > 4 { + cert = []byte(params.Args[4]) + } + + asset32 := uint32(asset) + req := &core.PostBondForm{ + AppPass: params.PWArgs[0], + Addr: params.Args[0], + Cert: cert, + Bond: bond, + Asset: &asset32, + LockTime: lockTimeEpoch, + } + return req, nil +} + func parseTradeArgs(params *RawParams) (*tradeForm, error) { if err := checkNArgs(params, []int{1}, []int{9}); err != nil { return nil, err diff --git a/client/rpcserver/types_test.go b/client/rpcserver/types_test.go index 7eef835947..af98ddb4e1 100644 --- a/client/rpcserver/types_test.go +++ b/client/rpcserver/types_test.go @@ -253,6 +253,8 @@ func TestCheckBoolArg(t *testing.T) { } } +// TODO: TestParseBondAssetArgs + func TestParseGetDEXConfigArgs(t *testing.T) { tests := []struct { name string diff --git a/dex/msgjson/types.go b/dex/msgjson/types.go index 4478dc3005..faeba54b8b 100644 --- a/dex/msgjson/types.go +++ b/dex/msgjson/types.go @@ -85,6 +85,7 @@ const ( BondAlreadyConfirmingError // 67 RPCWalletPeersError // 68 RPCNotificationsError // 69 + RPCPostBondError // 70 ) // Routes are destinations for a "payload" of data. The type of data being From fc77e8562821b73af6c5c02b4852646506b02af0 Mon Sep 17 00:00:00 2001 From: Jonathan Chappelow Date: Fri, 2 Jul 2021 18:02:33 -0500 Subject: [PATCH 5/5] client/webserver: add addbond, update register --- client/webserver/api.go | 49 ++++++++++++++++++++++++++++-- client/webserver/live_test.go | 9 ++++-- client/webserver/types.go | 14 +++++++-- client/webserver/webserver.go | 6 ++-- client/webserver/webserver_test.go | 10 ++++-- 5 files changed, 75 insertions(+), 13 deletions(-) diff --git a/client/webserver/api.go b/client/webserver/api.go index ba9bcccf20..27fa030daa 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -34,7 +34,7 @@ func (s *WebServer) apiDiscoverAccount(w http.ResponseWriter, r *http.Request) { return } defer zero(pass) - exchangeInfo, paid, err := s.core.DiscoverAccount(form.Addr, pass, cert) + exchangeInfo, paid, err := s.core.DiscoverAccount(form.Addr, pass, cert) // TODO: update when paid return removed if err != nil { s.writeAPIError(w, err) return @@ -248,6 +248,47 @@ func (s *WebServer) apiRegister(w http.ResponseWriter, r *http.Request) { writeJSON(w, simpleAck(), s.indent) } +// apiPostBond is the handler for the '/postbond' API request. +func (s *WebServer) apiPostBond(w http.ResponseWriter, r *http.Request) { + post := new(postBondForm) + defer post.Password.Clear() + if !readPost(w, r, post) { + return + } + assetID := uint32(42) + if post.AssetID != nil { + assetID = *post.AssetID + } + wallet := s.core.WalletState(assetID) + if wallet == nil { + s.writeAPIError(w, errors.New("no wallet")) + return + } + pass, err := s.resolvePass(post.Password, r) + if err != nil { + s.writeAPIError(w, fmt.Errorf("password error: %w", err)) + return + } + defer zero(pass) + + _, err = s.core.PostBond(&core.PostBondForm{ + Addr: post.Addr, + Cert: []byte(post.Cert), + AppPass: pass, + Bond: post.Bond, + Asset: &assetID, + LockTime: post.LockTime, + }) + if err != nil { + s.writeAPIError(w, fmt.Errorf("add bond error: %w", err)) + return + } + // There was no error paying the fee, but we must wait on confirmations + // before informing the DEX of the fee payment. Those results will come + // through as a notification. + writeJSON(w, simpleAck(), s.indent) +} + // apiNewWallet is the handler for the '/newwallet' API request. func (s *WebServer) apiNewWallet(w http.ResponseWriter, r *http.Request) { form := new(newWalletForm) @@ -490,7 +531,7 @@ func (s *WebServer) apiAccountExport(w http.ResponseWriter, r *http.Request) { return } defer zero(pass) - account, err := s.core.AccountExport(pass, form.Host) + account, _, err := s.core.AccountExport(pass, form.Host) if err != nil { s.writeAPIError(w, fmt.Errorf("error exporting account: %w", err)) return @@ -499,9 +540,11 @@ func (s *WebServer) apiAccountExport(w http.ResponseWriter, r *http.Request) { res := &struct { OK bool `json:"ok"` Account *core.Account `json:"account"` + Bonds []*db.Bond `json:"bonds"` }{ OK: true, Account: account, + // Bonds TODO } writeJSON(w, res, s.indent) } @@ -545,7 +588,7 @@ func (s *WebServer) apiAccountImport(w http.ResponseWriter, r *http.Request) { return } defer zero(pass) - err = s.core.AccountImport(pass, form.Account) + err = s.core.AccountImport(pass, form.Account, nil /* Bonds TODO */) if err != nil { s.writeAPIError(w, fmt.Errorf("error importing account: %w", err)) return diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 5bf03d3c6d..ae568f743c 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -601,6 +601,9 @@ func (c *TCore) Register(r *core.RegisterForm) (*core.RegisterResult, error) { c.reg = r return nil, nil } +func (c *TCore) PostBond(r *core.PostBondForm) (*core.PostBondResult, error) { + return nil, nil +} func (c *TCore) EstimateRegistrationTxFee(host string, certI interface{}, assetID uint32) (uint64, error) { xc := tExchanges[host] if xc == nil { @@ -769,10 +772,10 @@ func (c *TCore) PreOrder(*core.TradeForm) (*core.OrderEstimate, error) { }, nil } -func (c *TCore) AccountExport(pw []byte, host string) (*core.Account, error) { - return nil, nil +func (c *TCore) AccountExport(pw []byte, host string) (*core.Account, []*db.Bond, error) { + return nil, nil, nil } -func (c *TCore) AccountImport(pw []byte, account core.Account) error { +func (c *TCore) AccountImport(pw []byte, account *core.Account, bond []*db.Bond) error { return nil } func (c *TCore) AccountDisable(pw []byte, host string) error { return nil } diff --git a/client/webserver/types.go b/client/webserver/types.go index 6384675651..db8249aa9b 100644 --- a/client/webserver/types.go +++ b/client/webserver/types.go @@ -42,7 +42,17 @@ type registrationForm struct { Cert string `json:"cert"` Password encode.PassBytes `json:"pass"` Fee uint64 `json:"fee"` - AssetID *uint32 `json:"asset,omitempty"` // prevent out-of-date frontend from paying fee in BTC + AssetID *uint32 `json:"asset,omitempty"` // prevent omission using BTC +} + +// postBondForm is used to post a new bond for an existing DEX account. +type postBondForm struct { + Addr string `json:"addr"` + Cert string `json:"cert"` // may be empty for adding bond to existing account + Password encode.PassBytes `json:"pass"` + Bond uint64 `json:"bond"` + AssetID *uint32 `json:"asset,omitempty"` // prevent omission using BTC + LockTime uint64 `json:"lockTime"` } type registrationTxFeeForm struct { @@ -112,7 +122,7 @@ type accountExportForm struct { type accountImportForm struct { Pass encode.PassBytes `json:"pw"` - Account core.Account `json:"account"` + Account *core.Account `json:"account"` } type accountDisableForm struct { diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index bc7dcd4a96..8a27744546 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -93,6 +93,7 @@ type clientCore interface { Exchanges() map[string]*core.Exchange Exchange(host string) (*core.Exchange, error) Register(*core.RegisterForm) (*core.RegisterResult, error) + PostBond(form *core.PostBondForm) (*core.PostBondResult, error) Login(pw []byte) error InitializeClient(pw, seed []byte) error AssetBalance(assetID uint32) (*core.WalletBalance, error) @@ -124,8 +125,8 @@ type clientCore interface { Order(oid dex.Bytes) (*core.Order, error) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) - AccountExport(pw []byte, host string) (*core.Account, error) - AccountImport(pw []byte, account core.Account) error + AccountExport(pw []byte, host string) (*core.Account, []*db.Bond, error) + AccountImport(pw []byte, account *core.Account, bonds []*db.Bond) error AccountDisable(pw []byte, host string) error IsInitialized() bool ExportSeed(pw []byte) ([]byte, error) @@ -375,6 +376,7 @@ func New(cfg *Config) (*WebServer, error) { apiAuth.Get("/user", s.apiUser) apiAuth.Post("/defaultwalletcfg", s.apiDefaultWalletCfg) apiAuth.Post("/register", s.apiRegister) + apiAuth.Post("/postbond", s.apiPostBond) apiAuth.Post("/newwallet", s.apiNewWallet) apiAuth.Post("/openwallet", s.apiOpenWallet) apiAuth.Post("/depositaddress", s.apiNewDepositAddress) diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 9c51969c43..8087640193 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -59,6 +59,7 @@ type TCore struct { syncFeed core.BookFeed syncErr error regErr error + postBondErr error loginErr error logoutErr error initErr error @@ -95,6 +96,9 @@ func (c *TCore) DiscoverAccount(dexAddr string, pw []byte, certI interface{}) (* return nil, false, nil } func (c *TCore) Register(r *core.RegisterForm) (*core.RegisterResult, error) { return nil, c.regErr } +func (c *TCore) PostBond(r *core.PostBondForm) (*core.PostBondResult, error) { + return nil, c.postBondErr +} func (c *TCore) EstimateRegistrationTxFee(host string, certI interface{}, assetID uint32) (uint64, error) { return 0, nil } @@ -211,10 +215,10 @@ func (c *TCore) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate func (c *TCore) PreOrder(*core.TradeForm) (*core.OrderEstimate, error) { return nil, nil } -func (c *TCore) AccountExport(pw []byte, host string) (*core.Account, error) { - return nil, nil +func (c *TCore) AccountExport(pw []byte, host string) (*core.Account, []*db.Bond, error) { + return nil, nil, nil } -func (c *TCore) AccountImport(pw []byte, account core.Account) error { +func (c *TCore) AccountImport(pw []byte, account *core.Account, bonds []*db.Bond) error { return nil } func (c *TCore) AccountDisable(pw []byte, host string) error { return nil }