diff --git a/.gitignore b/.gitignore index 3d10c4ccec..c34556dd46 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ debug /vendor/ *.orig *.pprof +go.work +go.work.sum .DS_Store \.project dex*key @@ -27,6 +29,7 @@ client/cmd/mmbot/mmbot docs/examples/rpcclient/rpcclient dex/testing/loadbot/loadbot bin/ +bin-v*/ client/webserver/site/template-builder/template-builder dex/testing/btc/harnesschain.tar.gz client/asset/btc/electrum/example/server/server diff --git a/client/core/bookie.go b/client/core/bookie.go index c6fc6ade09..67f04a7ee7 100644 --- a/client/core/bookie.go +++ b/client/core/bookie.go @@ -814,38 +814,43 @@ func handleTradeResumptionMsg(c *Core, dc *dexConnection, msg *msgjson.Message) return nil } +func (dc *dexConnection) apiVersion() int32 { + return atomic.LoadInt32(&dc.apiVer) +} + // refreshServerConfig fetches and replaces server configuration data. It also // initially checks that a server's API version is one of serverAPIVers. -func (dc *dexConnection) refreshServerConfig() error { +func (dc *dexConnection) refreshServerConfig() (*msgjson.ConfigResult, error) { // Fetch the updated DEX configuration. cfg := new(msgjson.ConfigResult) err := sendRequest(dc.WsConn, msgjson.ConfigRoute, nil, cfg, DefaultResponseTimeout) if err != nil { - return fmt.Errorf("unable to fetch server config: %w", err) + return nil, fmt.Errorf("unable to fetch server config: %w", err) + } + + // (V0PURGE) Infer if the server advertises API v0 but supports the upcoming + // v1 routes such as postbond. Server can only advertise a single version + // presently so we have to detect this indirectly. + apiVer := int32(cfg.APIVersion) + if apiVer == 0 && cfg.BondExpiry > 0 { + apiVer = 1 } + dc.log.Infof("Server %v supports API version %v.", dc.acct.host, apiVer) + atomic.StoreInt32(&dc.apiVer, apiVer) // Check that we are able to communicate with this DEX. - apiVer := atomic.LoadInt32(&dc.apiVer) - cfgAPIVer := int32(cfg.APIVersion) - - if apiVer != cfgAPIVer { - if found := func() bool { - for _, version := range serverAPIVers { - ver := int32(version) - if cfgAPIVer == ver { - dc.log.Debugf("Setting server api version to %v.", ver) - atomic.StoreInt32(&dc.apiVer, ver) - return true - } - } - return false - }(); !found { - err := fmt.Errorf("unknown server API version: %v", cfgAPIVer) - if cfgAPIVer > apiVer { - err = fmt.Errorf("%v: %w", err, outdatedClientErr) - } - return err + var supported bool + for _, ver := range supportedAPIVers { + if apiVer == ver { + supported = true + } + } + if !supported { + err := fmt.Errorf("unsupported server API version %v", apiVer) + if apiVer > supportedAPIVers[len(supportedAPIVers)-1] { + err = fmt.Errorf("%v: %w", err, outdatedClientErr) } + return nil, err } bTimeout := time.Millisecond * time.Duration(cfg.BroadcastTimeout) @@ -854,6 +859,7 @@ func (dc *dexConnection) refreshServerConfig() error { if dc.ticker.Dur() != tickInterval { dc.ticker.Reset(tickInterval) } + getAsset := func(id uint32) *msgjson.Asset { for _, asset := range cfg.Assets { if id == asset.ID { @@ -916,10 +922,10 @@ func (dc *dexConnection) refreshServerConfig() error { assets, epochs, err := generateDEXMaps(dc.acct.host, cfg) if err != nil { - return fmt.Errorf("inconsistent 'config' response: %w", err) + return nil, fmt.Errorf("inconsistent 'config' response: %w", err) } - // Update dc.{marketMap,epoch,assets} + // Update dc.{epoch,assets} dc.assetsMtx.Lock() dc.assets = assets dc.assetsMtx.Unlock() @@ -932,7 +938,7 @@ func (dc *dexConnection) refreshServerConfig() error { if dc.acct.dexPubKey == nil && len(cfg.DEXPubKey) > 0 { dc.acct.dexPubKey, err = secp256k1.ParsePubKey(cfg.DEXPubKey) if err != nil { - return fmt.Errorf("error decoding secp256k1 PublicKey from bytes: %w", err) + return nil, fmt.Errorf("error decoding secp256k1 PublicKey from bytes: %w", err) } } @@ -940,7 +946,7 @@ func (dc *dexConnection) refreshServerConfig() error { dc.epoch = epochs dc.epochMtx.Unlock() - return nil + return cfg, nil } // subPriceFeed subscribes to the price_feed notification feed and primes the diff --git a/client/core/core.go b/client/core/core.go index 8aa504718b..d5e6828740 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -74,13 +74,13 @@ var ( // When waiting for a wallet to sync, a SyncStatus check will be performed // every syncTickerPeriod. var instead of const for testing purposes. syncTickerPeriod = 3 * time.Second - // serverAPIVers are the DEX server API versions this client is capable + // supportedAPIVers are the DEX server API versions this client is capable // of communicating with. // // NOTE: API version may change at any time. Keep this in mind when // updating the API. Long-running operations may start and end with // differing versions. - serverAPIVers = []int{serverdex.PreAPIVersion} + supportedAPIVers = []int32{serverdex.PreAPIVersion, serverdex.BondAPIVersion} // ActiveOrdersLogoutErr is returned from logout when there are active // orders. ActiveOrdersLogoutErr = errors.New("cannot log out with active orders") @@ -151,6 +151,8 @@ type dexConnection struct { // connectionStatus is a best guess on the ws connection status. connectionStatus uint32 + // pendingFee is deprecated, and will be removed when v0 API support is + // dropped in favor of v1 with bonds. (V0PURGE) pendingFeeMtx sync.RWMutex pendingFee *pendingFeeState @@ -395,10 +397,16 @@ func (dc *dexConnection) exchangeInfo() *Exchange { Host: dc.acct.host, AcctID: acctID, ConnectionStatus: dc.status(), - PendingFee: dc.getPendingFee(), + PendingFee: dc.getPendingFee(), // V0PURGE - deprecated with bonds in v1 } } + bondAssets := make(map[string]*BondAsset, len(cfg.BondAssets)) + for symb, bondAsset := range cfg.BondAssets { + coreBondAsset := BondAsset(*bondAsset) // convert msgjson.BondAsset to core.BondAsset + bondAssets[symb] = &coreBondAsset + } + dc.assetsMtx.RLock() assets := make(map[uint32]*dex.Asset, len(dc.assets)) for assetID, dexAsset := range dc.assets { @@ -416,6 +424,7 @@ func (dc *dexConnection) exchangeInfo() *Exchange { } dcrAsset := feeAssets["dcr"] if dcrAsset == nil { // should have happened in refreshServerConfig + // V0PURGE dcrAsset = &FeeAsset{ ID: 42, Amt: cfg.Fee, @@ -424,16 +433,29 @@ func (dc *dexConnection) exchangeInfo() *Exchange { feeAssets["dcr"] = dcrAsset } + dc.acct.authMtx.RLock() + // TODO: List bonds in core.Exchange. For now, just tier. + // bondsPending := len(dc.acct.pendingBonds) > 0 + tier := dc.acct.tier + dc.acct.authMtx.RUnlock() + return &Exchange{ Host: dc.acct.host, AcctID: acctID, Markets: dc.marketMap(), Assets: assets, + BondExpiry: cfg.BondExpiry, + BondAssets: bondAssets, ConnectionStatus: dc.status(), - Fee: dcrAsset, - RegFees: feeAssets, - PendingFee: dc.getPendingFee(), CandleDurs: cfg.BinSizes, + Tier: tier, + BondsPending: false, + // TODO: Bonds + + // Legacy reg fee (V0PURGE) + Fee: dcrAsset, + RegFees: feeAssets, + PendingFee: dc.getPendingFee(), } } @@ -3439,20 +3461,24 @@ func (c *Core) discoverAccount(dc *dexConnection, crypter encrypt.Crypter) (bool } return false, newError(authErr, "unexpected authDEX error: %w", err) } - if dc.acct.isSuspended { - c.log.Infof("HD account key for %s was reported as suspended. Deriving another account key.", dc.acct.host) + // 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.", + dc.acct.host, dc.acct.tier) keyIndex++ time.Sleep(200 * time.Millisecond) // don't hammer continue } - break // great, the account at this key index is paid and ready + break // great, the account at this key index exists } - // Actual fee asset ID and coin are unknown, but paid. - dc.acct.isPaid = true - dc.acct.feeCoin = []byte("DUMMY COIN") - dc.acct.feeAssetID = 42 + if dc.acct.legacyFeePaid { + // Actual fee asset ID and coin are unknown, but paid. + dc.acct.isPaid = true + dc.acct.feeCoin = []byte("DUMMY COIN") + dc.acct.feeAssetID = 42 + } err := c.db.CreateAccount(&db.AccountInfo{ Host: dc.acct.host, @@ -3522,6 +3548,10 @@ func (c *Core) upgradeConnection(dc *dexConnection) { // to register on a DEX, and Register may be called directly, although it requires // the expected fee amount as an additional input and it will pay the fee if the // account is not discovered and paid. +// +// The Tier and BondsPending fields may be consulted to determine if it is still +// necessary to PostBond (i.e. Tier == 0 && !BondsPending) before trading. The +// Connected field should be consulted first. func (c *Core) DiscoverAccount(dexAddr string, appPW []byte, certI interface{}) (*Exchange, bool, error) { if !c.IsInitialized() { return nil, false, fmt.Errorf("cannot register DEX because app has not been initialized") @@ -3538,8 +3568,16 @@ func (c *Core) DiscoverAccount(dexAddr string, appPW []byte, certI interface{}) } defer crypter.Close() + c.connMtx.RLock() + dc, found := c.conns[host] + c.connMtx.RUnlock() + if found { + // Already registered, but connection may be down and/or PostBond needed. + return dc.exchangeInfo(), true, nil // *Exchange has Tier and BondsPending + } + var ready bool - dc, err := c.tempDexConnection(host, certI) + dc, err = c.tempDexConnection(host, certI) if dc != nil { // (re)connect loop may be running even if err != nil defer func() { // Either disconnect or promote this connection. @@ -3695,10 +3733,8 @@ func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { // Ensure this DEX supports this asset for registration fees, and get the // required confirmations and fee amount. - dc.cfgMtx.RLock() - feeAsset, supported := dc.cfg.RegFees[regFeeAssetSymbol] - dc.cfgMtx.RUnlock() - if !supported || feeAsset == nil { + feeAsset := dc.feeAsset(regFeeAssetID) // dc.cfg.RegFees[regFeeAssetSymbol] + if feeAsset == nil { return nil, newError(assetSupportErr, "dex server does not accept registration fees in asset %q", regFeeAssetSymbol) } if feeAsset.ID != regFeeAssetID { @@ -5691,15 +5727,29 @@ func (c *Core) authDEX(dc *dexConnection) error { return newError(signatureErr, "DEX signature validation error: %w", err) } - var suspended bool - if result.Suspended != nil { - suspended = *result.Suspended + var tier int64 + var legacyFeePaid bool + if result.Tier == nil { // legacy server (V0PURGE) + // A legacy server does not set ConnectResult.LegacyFeePaid, but unpaid + // legacy ('register') users get an UnpaidAccountError from Connect, so + // we know the account is paid and not suspended. + legacyFeePaid = true + if result.Suspended == nil || !*result.Suspended { + tier = 1 + } + } else { + tier = *result.Tier + if result.LegacyFeePaid != nil { + legacyFeePaid = *result.LegacyFeePaid + } } // Set the account as authenticated. - c.log.Debugf("Authenticated connection to %s, acct %v, %d active orders, %d active matches, score %d (suspended = %v)", - dc.acct.host, acctID, len(result.ActiveOrderStatuses), len(result.ActiveMatches), result.Score, suspended) - dc.acct.auth(suspended) + c.log.Infof("Authenticated connection to %s, acct %v, %d active bonds, %d active orders, %d active matches, score %d, tier %d", + dc.acct.host, acctID, len(result.ActiveBonds), len(result.ActiveOrderStatuses), len(result.ActiveMatches), result.Score, tier) + // Flag as authenticated before bondConfirmed / monitorBondConfs, which may + // call authDEX if not flagged as such. + dc.acct.auth(tier, legacyFeePaid) // Associate the matches with known trades. matches, _, err := dc.parseMatches(result.ActiveMatches, false) @@ -6968,7 +7018,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo, temporary ...bool) (*dexConn } // Request the market configuration. - err = dc.refreshServerConfig() // handleReconnect must too + _, err = dc.refreshServerConfig() // handleReconnect must too if err != nil { if errors.Is(err, outdatedClientErr) { sendOutdatedClientNotification(c, dc) @@ -7000,7 +7050,7 @@ func (c *Core) handleReconnect(host string) { // The server's configuration may have changed, so retrieve the current // server configuration. - err := dc.refreshServerConfig() + cfg, err := dc.refreshServerConfig() if err != nil { if errors.Is(err, outdatedClientErr) { sendOutdatedClientNotification(c, dc) @@ -7014,16 +7064,14 @@ func (c *Core) handleReconnect(host string) { base uint32 quote uint32 } - dc.cfgMtx.RLock() mkts := make(map[string]*market, len(dc.cfg.Markets)) - for _, m := range dc.cfg.Markets { + for _, m := range cfg.Markets { mkts[m.Name] = &market{ name: m.Name, base: m.Base, quote: m.Quote, } } - dc.cfgMtx.RUnlock() // Update the orders' selfGoverned flag according to the configured markets. for _, trade := range dc.trackedTrades() { diff --git a/client/core/core_test.go b/client/core/core_test.go index 64a9cbca51..4029d3ccbb 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -207,6 +207,9 @@ func tNewAccount(crypter *tCrypter) *dexAccount { privKey: privKey, id: account.NewID(privKey.PubKey().SerializeCompressed()), feeCoin: []byte("somecoin"), + // feeAssetID is 0 (btc) + // tier, bonds, etc. set on auth + tier: 1, // not suspended by default } } @@ -228,6 +231,8 @@ func testDexConnection(ctx context.Context, crypter *tCrypter) (*dexConnection, }, books: make(map[string]*bookie), cfg: &msgjson.ConfigResult{ + APIVersion: serverdex.PreAPIVersion, + DEXPubKey: acct.dexPubKey.SerializeCompressed(), CancelMax: 0.8, BroadcastTimeout: 1000, // 1000 ms for faster expiration, but ticker fires fast Assets: []*msgjson.Asset{ @@ -264,9 +269,12 @@ func testDexConnection(ctx context.Context, crypter *tCrypter) (*dexConnection, }, }, }, + BondExpiry: 86400, // >0 make client treat as API v1 + BondAssets: map[string]*msgjson.BondAsset{ + "dcr": {ID: 42, Amt: tFee, Confs: 1}, + }, Fee: tFee, - RegFeeConfirms: 0, - DEXPubKey: acct.dexPubKey.SerializeCompressed(), + RegFeeConfirms: 0, // 1 or remove? BinSizes: []string{"1h", "24h"}, }, notify: func(Notification) {}, @@ -1211,11 +1219,6 @@ func newTestRig() *testRig { legacyKeyErr: tErr, } - ai := &db.AccountInfo{ - Host: "somedex.com", - } - tdb.acct = ai - // Set the global waiter expiration, and start the waiter. queue := wait.NewTickerQueue(time.Millisecond * 5) ctx, cancel := context.WithCancel(tCtx) @@ -1229,6 +1232,18 @@ func newTestRig() *testRig { crypter := &tCrypter{} dc, conn, acct := testDexConnection(ctx, crypter) // crypter makes acct.encKey consistent with privKey + ai := &db.AccountInfo{ + Host: "somedex.com", + Cert: acct.cert, + DEXPubKey: acct.dexPubKey, + EncKeyV2: acct.encKey, + // Bonds: nil, + // LegacyFeeCoin: acct.feeCoin, + // LegacyFeeAssetID: acct.feeAssetID, + // LegacyFeePaid: true, + } + tdb.acct = ai + shutdown := func() { cancel() wg.Wait() @@ -1350,6 +1365,10 @@ func (rig *testRig) queueConnect(rpcErr *msgjson.Error, matches []*msgjson.Match result := &msgjson.ConnectResult{Sig: connect.Sig, ActiveMatches: matches, ActiveOrderStatuses: orders} if len(suspended) > 0 { result.Suspended = &suspended[0] + if suspended[0] { + tier := int64(-1) // even <0 so keys cycle even with v1 api + result.Tier = &tier + } } var resp *msgjson.Message if rpcErr != nil { @@ -5683,7 +5702,7 @@ func TestResolveActiveTrades(t *testing.T) { defer rig.shutdown() tCore := rig.core - rig.acct.auth(false) // Short path through initializeDEXConnections + rig.acct.auth(1, false) // Short path through initializeDEXConnections utxoAsset /* base */, acctAsset /* quote */ := tUTXOAssetB, tACCTAsset @@ -5848,7 +5867,7 @@ func TestReReserveFunding(t *testing.T) { defer rig.shutdown() tCore := rig.core - rig.acct.auth(false) // Short path through initializeDEXConnections + rig.acct.auth(1, false) // Short path through initializeDEXConnections utxoAsset /* base */, acctAsset /* quote */ := tUTXOAssetB, tACCTAsset @@ -9556,9 +9575,9 @@ func TestRefreshServerConfig(t *testing.T) { rig := newTestRig() defer rig.shutdown() - // Add an API version to serverAPIVers to use in tests. - newAPIVer := ^uint16(0) - 1 - serverAPIVers = append(serverAPIVers, int(newAPIVer)) + // Add an API version to supportedAPIVers to use in tests. + const newAPIVer = ^uint16(0) - 1 + supportedAPIVers = append(supportedAPIVers, int32(newAPIVer)) queueConfig := func(err *msgjson.Error, apiVer uint16) { rig.ws.queueResponse(msgjson.ConfigRoute, func(msg *msgjson.Message, f msgFunc) error { @@ -9598,7 +9617,7 @@ func TestRefreshServerConfig(t *testing.T) { for _, test := range tests { rig.dc.cfg.Markets[0].Base = test.marketBase queueConfig(test.configErr, test.gotAPIVer) - err := rig.dc.refreshServerConfig() + _, err := rig.dc.refreshServerConfig() if test.wantErr { if err == nil { t.Fatalf("expected error for test %q", test.name) diff --git a/client/core/types.go b/client/core/types.go index 54cf0a144f..377f253c57 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -540,16 +540,21 @@ func (m *Market) ConventionalRateToMsg(p float64) uint64 { return uint64(math.Round(p / m.AtomToConv * calc.RateEncodingFactor)) } -// FeeAsset has an analogous msgjson type for server providing supported -// registration fee assets. -type FeeAsset struct { - ID uint32 `json:"id"` - Confs uint32 `json:"confs"` - Amt uint64 `json:"amount"` +// BondAsset describes the bond asset in terms of it's BIP-44 coin type, +// required confirmations, and minimum bond amount. There is an analogous +// msgjson type for server providing supported bond assets. +type BondAsset struct { + Version uint16 `json:"ver"` + ID uint32 `json:"id"` + Confs uint32 `json:"confs"` + Amt uint64 `json:"amount"` } +// FeeAsset is deprecated (V0PURGE), but the same as BondAsset. +type FeeAsset BondAsset + // PendingFeeState conveys a pending registration fee's asset and current -// confirmation count. +// confirmation count. Deprecated (V0PURGE). type PendingFeeState struct { Symbol string `json:"symbol"` AssetID uint32 `json:"assetID"` @@ -562,11 +567,18 @@ type Exchange struct { AcctID string `json:"acctID"` Markets map[string]*Market `json:"markets"` Assets map[uint32]*dex.Asset `json:"assets"` + BondExpiry uint64 `json:"bondExpiry"` + BondAssets map[string]*BondAsset `json:"bondAssets"` ConnectionStatus comms.ConnectionStatus `json:"connectionStatus"` - Fee *FeeAsset `json:"feeAsset"` // DEPRECATED. DCR. - RegFees map[string]*FeeAsset `json:"regFees"` - PendingFee *PendingFeeState `json:"pendingFee,omitempty"` CandleDurs []string `json:"candleDurs"` + Tier int64 `json:"tier"` + BondsPending bool `json:"bondsPending"` + // TODO: a Bonds slice + + // OLD fields for the legacy registration fee (V0PURGE): + Fee *FeeAsset `json:"feeAsset"` // DCR. DEPRECATED by RegFees. + RegFees map[string]*FeeAsset `json:"regFees"` + PendingFee *PendingFeeState `json:"pendingFee,omitempty"` } // newDisplayIDFromSymbols creates a display-friendly market ID for a base/quote @@ -656,26 +668,33 @@ 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 + + // Legacy reg fee (V0PURGE) feeAssetID uint32 feeCoin []byte - - authMtx sync.RWMutex - isPaid bool // feeCoin fully confirmed, ready to trade - isAuthed bool - isSuspended bool + isPaid bool // feeCoin fully confirmed + // Instead of isSuspended, set tier=0 if legacy fee paid and server + // indicates the account is suspended. } // newDEXAccount is a constructor for a new *dexAccount. func newDEXAccount(acctInfo *db.AccountInfo) *dexAccount { return &dexAccount{ host: acctInfo.Host, - encKey: acctInfo.EncKey(), + cert: acctInfo.Cert, dexPubKey: acctInfo.DEXPubKey, - isPaid: acctInfo.Paid, + encKey: acctInfo.EncKey(), // privKey and id on decrypt feeAssetID: acctInfo.FeeAssetID, feeCoin: acctInfo.FeeCoin, - cert: acctInfo.Cert, - // isSuspended is determined on connect, not stored + isPaid: acctInfo.Paid, + // bonds are set separately when categorized in authDEX } } @@ -795,12 +814,12 @@ func (a *dexAccount) authed() bool { return a.isAuthed } -// auth sets the account as authenticated, but possibly suspended (may not place -// new orders, but may still be negotiating swaps). -func (a *dexAccount) auth(suspended bool) { +// auth sets the account as authenticated at the provided tier. +func (a *dexAccount) auth(tier int64, legacyFeePaid bool) { a.authMtx.Lock() a.isAuthed = true - a.isSuspended = suspended + a.tier = tier + a.legacyFeePaid = legacyFeePaid a.authMtx.Unlock() } @@ -815,7 +834,13 @@ func (a *dexAccount) unauth() { func (a *dexAccount) suspended() bool { a.authMtx.RLock() defer a.authMtx.RUnlock() - return a.isSuspended + 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 @@ -854,6 +879,12 @@ func (a *dexAccount) sign(msg []byte) ([]byte, error) { // checkSig checks the signature against the message and the DEX pubkey. func (a *dexAccount) checkSig(msg []byte, sig []byte) error { + if msg == nil { + return fmt.Errorf("no message to verify") + } + if sig == nil { + return fmt.Errorf("no signature to verify") + } return checkSigS256(msg, a.dexPubKey.SerializeCompressed(), sig) } diff --git a/dex/asset.go b/dex/asset.go index f10a51e7c6..b99462139b 100644 --- a/dex/asset.go +++ b/dex/asset.go @@ -16,6 +16,12 @@ const ( defaultLockTimeTaker = 8 * time.Hour defaultlockTimeMaker = 20 * time.Hour + + secondsPerMinute int64 = 60 + secondsPerDay = 24 * 60 * secondsPerMinute + BondExpiryMainnet = 30 * secondsPerDay // 30 days + BondExpiryTestnet = 90 * secondsPerMinute // 90 minutes + BondExpirySimnet = 4 * secondsPerMinute // 4 minutes ) var ( @@ -73,6 +79,21 @@ func LockTimeMaker(network Network) time.Duration { return testLockTime.maker } +// BondExpiry returns the bond expiry duration in seconds for a given network. +// Once APIVersion reaches BondAPIVersion, clients should use this compiled +// helper function. Until then, bonds are considered experimental and the +// current value should be referenced from config response. +func BondExpiry(net Network) int64 { + switch net { + case Mainnet: + return BondExpiryMainnet + case Testnet: + return BondExpiryTestnet + default: // Regtest, Simnet, other + return BondExpirySimnet + } +} + // Network flags passed to asset backends to signify which network to use. type Network uint8 diff --git a/dex/msgjson/types.go b/dex/msgjson/types.go index f295640672..f3069c4ebb 100644 --- a/dex/msgjson/types.go +++ b/dex/msgjson/types.go @@ -30,7 +30,7 @@ const ( RPCOpenWalletError // 12 RPCWalletExistsError // 13 RPCCloseWalletError // 14 - RPCGetFeeError // 15 + RPCGetFeeError // 15, obsolete, kept for order RPCRegisterError // 16 RPCArgumentsError // 17 RPCTradeError // 18 @@ -72,7 +72,7 @@ const ( HTTPRouteError // 54 RouteUnavailableError // 55 AccountExistsError // 56 - AccountSuspendedError // 57 + AccountSuspendedError // 57 deprecated, kept for order RPCExportSeedError // 58 TooManyRequestsError // 59 RPCGetDEXConfigError // 60 @@ -81,6 +81,8 @@ const ( RPCDeleteArchivedRecordsError // 63 DuplicateRequestError // 64 RPCToggleWalletStatusError // 65 + BondError // 66 + BondAlreadyConfirmingError // 67 ) // Routes are destinations for a "payload" of data. The type of data being @@ -156,12 +158,26 @@ const ( // authentication so that the connection can be used for trading. ConnectRoute = "connect" // RegisterRoute is the client-originating request-type message initiating a - // new client registration. + // new client registration. DEPRECATED with bonds (V0PURGE) RegisterRoute = "register" // NotifyFeeRoute is the client-originating request-type message informing the // DEX that the fee has been paid and has the requisite number of - // confirmations. + // confirmations. DEPRECATED with bonds (V0PURGE) NotifyFeeRoute = "notifyfee" + // PostBondRoute is the client-originating request used to post a new + // fidelity bond. This can create a new account or it can add bond to an + // existing account. + PostBondRoute = "postbond" + // PreValidateBondRoute is the client-originating request used to + // pre-validate a fidelity bond transaction before broadcasting it (and + // locking funds for months). + PreValidateBondRoute = "prevalidatebond" + // BondExpiredRoute is a server-originating notification when a bond expires + // according to the configure bond expiry duration and the bond's lock time. + BondExpiredRoute = "bondexpired" + // TierChangeRoute is a server-originating notification sent to a connected + // user who's tier changes for any reason. + TierChangeRoute = "tierchange" // (TODO: use in many auth mgr events) // ConfigRoute is the client-originating request-type message requesting the // DEX configuration information. ConfigRoute = "config" @@ -281,6 +297,8 @@ type ResponsePayload struct { // decoded depends on its MessageType. type MessageType uint8 +// There are presently three recognized message types: request, response, and +// notification. const ( InvalidMessageType MessageType = iota // 0 Request // 1 @@ -471,7 +489,7 @@ func (m *Match) Serialize() []byte { return append(s, uint64Bytes(m.FeeRateQuote)...) } -// Nomatch is the payload for a server-originating NoMatchRoute notification. +// NoMatch is the payload for a server-originating NoMatchRoute notification. type NoMatch struct { OrderID Bytes `json:"orderid"` } @@ -484,7 +502,7 @@ type MatchRequest struct { MatchID Bytes `json:"matchid"` } -// MatchStatus is the successful result for the MatchStatusRoute request. +// MatchStatusResult is the successful result for the MatchStatusRoute request. type MatchStatusResult struct { MatchID Bytes `json:"matchid"` Status uint8 `json:"status"` @@ -631,6 +649,9 @@ func (r *Redemption) Serialize() []byte { return append(s, uint64Bytes(r.Time)...) } +// Certain order properties are specified with the following constants. These +// properties include buy/sell (side), standing/immediate (force), +// limit/market/cancel (order type). const ( BuyOrderNum = 1 SellOrderNum = 2 @@ -811,12 +832,12 @@ type BookOrderNote struct { TradeNote } -// UnbookOrderRoute is the DEX-originating notification-type message informing +// UnbookOrderNote is the DEX-originating notification-type message informing // the client to remove an order from the order book. type UnbookOrderNote OrderNote -// EpochOrderRoute is the DEX-originating notification-type message informing -// the client about an order added to the epoch queue. +// EpochOrderNote is the DEX-originating notification-type message informing the +// client about an order added to the epoch queue. type EpochOrderNote struct { BookOrderNote Commit Bytes `json:"com"` @@ -904,6 +925,16 @@ func (c *Connect) Serialize() []byte { return append(s, uint64Bytes(c.Time)...) } +// Bond is information on a fidelity bond. This is part of the ConnectResult and +// PostBondResult payloads. +type Bond struct { + Version uint16 `json:"version"` + Amount uint64 `json:"amount"` + Expiry uint64 `json:"expiry"` // when it expires, not the lock time + CoinID Bytes `json:"coinID"` // NOTE: ID capitalization not consistent with other payloads, but internally consistent with assetID + AssetID uint32 `json:"assetID"` +} + // ConnectResult is the result for the ConnectRoute request. // // TODO: Include penalty data as specified in the spec. @@ -912,7 +943,29 @@ type ConnectResult struct { ActiveOrderStatuses []*OrderStatus `json:"activeorderstatuses"` ActiveMatches []*Match `json:"activematches"` Score int32 `json:"score"` - Suspended *bool `json:"suspended,omitempty"` // will be implied (obsolete) with tiers and bonds + Tier *int64 `json:"tier"` // 1+ means bonded and may trade, a function of active bond amounts and conduct, nil legacy + ActiveBonds []*Bond `json:"activeBonds"` + LegacyFeePaid *bool `json:"legacyFeePaid"` // not set by legacy server + + Suspended *bool `json:"suspended,omitempty"` // DEPRECATED - implied by tier<1 +} + +// TierChangedNotification is the dex-originating notification send when the +// user's tier changes as a result of account conduct violations. Tier change +// due to bond expiry is communicated with a BondExpiredNotification. +type TierChangedNotification struct { + Signature + // AccountID Bytes `json:"accountID"` + Tier int64 `json:"tier"` + Reason string `json:"reason"` +} + +// Serialize serializes the TierChangedNotification data. +func (tc *TierChangedNotification) Serialize() []byte { + // serialization: tier (8) + reason (variable string) + b := make([]byte, 0, 8+len(tc.Reason)) + b = append(b, uint64Bytes(uint64(tc.Tier))...) + return append(b, []byte(tc.Reason)...) } // PenaltyNote is the payload of a Penalty notification. @@ -926,7 +979,7 @@ type PenaltyNote struct { type Penalty struct { Rule account.Rule `json:"rule"` Time uint64 `json:"timestamp"` - Duration uint64 `json:"duration"` + Duration uint64 `json:"duration,omitempty"` // DEPRECATED with bonding tiers, but must remain in serialization until v1 (V0PURGE) Details string `json:"details"` } @@ -942,6 +995,129 @@ func (n *PenaltyNote) Serialize() []byte { return append(b, []byte(p.Details)...) } +// Client should send bond info when their bond tx is fully-confirmed. Server +// should start waiting for required confs when it receives the 'postbond' +// request if the txn is found. Client is responsible for submitting 'postbond' +// for their bond txns when they reach required confs. Implementation note: the +// client should also check on startup for stored bonds that are neither +// accepted nor expired yet (also maybe if not listed in the 'connect' +// response), and post those. + +// PreValidateBond may provide the unsigned bond transaction for validation +// prior to broadcasting the signed transaction. If they skip pre-validation, +// and the broadcasted transaction is rejected, the client would have needlessly +// locked funds. +type PreValidateBond struct { + Signature + AcctPubKey Bytes `json:"acctPubKey"` // acctID = blake256(blake256(acctPubKey)) + AssetID uint32 `json:"assetID"` + Version uint16 `json:"version"` + RawTx Bytes `json:"tx"` + // Data Bytes `json:"data"` // needed for some assets? e.g. redeem script or contract key +} + +// Serialize serializes the PreValidateBond data for the signature. +func (pb *PreValidateBond) Serialize() []byte { + // serialization: client pubkey (33) + asset ID (4) + bond version (2) + + // raw tx (variable) + sz := len(pb.AcctPubKey) + 4 + 2 + len(pb.RawTx) // + len(pb.Data) + b := make([]byte, 0, sz) + b = append(b, pb.AcctPubKey...) + b = append(b, uint32Bytes(pb.AssetID)...) + b = append(b, uint16Bytes(pb.Version)...) + return append(b, pb.RawTx...) + // return append(b, pb.Data...) +} + +// PreValidateBondResult is the response to the client's PreValidateBond +// request. +type PreValidateBondResult struct { + Signature + AccountID Bytes `json:"accountID"` + AssetID uint32 `json:"assetID"` + Amount uint64 `json:"amount"` + Expiry uint64 `json:"expiry"` // not locktime, but time when bond expires for dex + BondID Bytes `json:"bondID"` +} + +// Serialize serializes the PreValidateBondResult data for the signature. +func (pbr *PreValidateBondResult) Serialize() []byte { + sz := len(pbr.AccountID) + 4 + 8 + 8 + len(pbr.BondID) + b := make([]byte, 0, sz) + b = append(b, pbr.AccountID...) + b = append(b, uint32Bytes(pbr.AssetID)...) + b = append(b, uint64Bytes(pbr.Amount)...) + b = append(b, uint64Bytes(pbr.Expiry)...) + return append(b, pbr.BondID...) +} + +// PostBond requests that server accept a confirmed bond payment, specified by +// the provided CoinID, for a certain account. +type PostBond struct { + Signature + AcctPubKey Bytes `json:"acctPubKey"` // acctID = blake256(blake256(acctPubKey)) + AssetID uint32 `json:"assetID"` + Version uint16 `json:"version"` + CoinID Bytes `json:"coinid"` + // For an account-based asset where there is a central bond contract implied + // by Version, do we use AcctPubKey to lookup bonded amount, and CoinID to + // wait for confs of this latest bond addition? + // Data Bytes `json:"data"` +} + +// Serialize serializes the PostBond data for the signature. +func (pb *PostBond) Serialize() []byte { + // serialization: client pubkey (33) + asset ID (4) + bond version (2) + + // coin ID (variable) + sz := len(pb.AcctPubKey) + 4 + 2 + len(pb.CoinID) + b := make([]byte, 0, sz) + b = append(b, pb.AcctPubKey...) + b = append(b, uint32Bytes(pb.AssetID)...) + b = append(b, uint16Bytes(pb.Version)...) + return append(b, pb.CoinID...) +} + +// PostBondResult is the response to the client's PostBond request. If Active is +// true, the bond was applied to the account; if false it is not confirmed, but +// was otherwise validated. +type PostBondResult struct { + Signature // message is BondID | AccountID + AccountID Bytes `json:"accountID"` + AssetID uint32 `json:"assetID"` + Amount uint64 `json:"amount"` + Expiry uint64 `json:"expiry"` // not locktime, but time when bond expires for dex + BondID Bytes `json:"bondID"` + Tier int64 `json:"tier"` +} + +// Serialize serializes the PostBondResult data for the signature. +func (pbr *PostBondResult) Serialize() []byte { + sz := len(pbr.AccountID) + len(pbr.BondID) + b := make([]byte, 0, sz) + b = append(b, pbr.AccountID...) + return append(b, pbr.BondID...) +} + +// BondExpiredNotification is a notification from a server when a bond tx +// expires. +type BondExpiredNotification struct { + Signature + AccountID Bytes `json:"accountID"` + AssetID uint32 `json:"assetid"` + BondCoinID Bytes `json:"coinid"` + Tier int64 `json:"tier"` +} + +// Serialize serializes the BondExpiredNotification data. +func (bc *BondExpiredNotification) Serialize() []byte { + sz := 4 + len(bc.AccountID) + 4 + len(bc.BondCoinID) + 8 + b := make([]byte, 0, sz) + b = append(b, bc.AccountID...) + b = append(b, uint32Bytes(bc.AssetID)...) + b = append(b, bc.BondCoinID...) + return append(b, uint64Bytes(uint64(bc.Tier))...) // correct bytes for int64 (signed)? +} + // Register is the payload for the RegisterRoute request. type Register struct { Signature @@ -1080,19 +1256,41 @@ type FeeAsset struct { Amt uint64 `json:"amount"` } +// BondAsset describes an asset for which fidelity bonds are supported. +type BondAsset struct { + Version uint16 `json:"version"` // latest version supported + ID uint32 `json:"id"` + Confs uint32 `json:"confs"` + Amt uint64 `json:"amount"` // to be implied by bond version? +} + // ConfigResult is the successful result for the ConfigRoute. type ConfigResult struct { + // APIVersion is the server's communications API version, but we may + // consider APIVersions []uint16, with versioned routes e.g. "initV2". + // APIVersions []uint16 `json:"apivers"` + APIVersion uint16 `json:"apiver"` + DEXPubKey dex.Bytes `json:"pubkey"` CancelMax float64 `json:"cancelmax"` BroadcastTimeout uint64 `json:"btimeout"` - RegFeeConfirms uint16 `json:"regfeeconfirms"` // DEPRECATED Assets []*Asset `json:"assets"` Markets []*Market `json:"markets"` - Fee uint64 `json:"fee"` // DEPRECATED - APIVersion uint16 `json:"apiver"` BinSizes []string `json:"binSizes"` // Just apidata.BinSizes for now. - DEXPubKey Bytes `json:"pubkey"` - RegFees map[string]*FeeAsset `json:"regFees"` + BondAssets map[string]*BondAsset `json:"bondAssets"` + // BondExpiry defines the duration of time remaining until lockTime below + // which a bond is considered expired. As such, bonds should be created with + // a considerably longer lockTime. NOTE: BondExpiry in the config response + // is temporary, removed when APIVersion reaches BondAPIVersion and we have + // codified the expiries for each network (main,test,sim). Until then, the + // value will be considered variable, and we will communicate to the clients + // what we expect at any given time. BondAsset.Amt may also become implied + // by bond version. + BondExpiry uint64 `json:"DEV_bondExpiry"` + + RegFees map[string]*FeeAsset `json:"regFees"` + Fee uint64 `json:"fee"` // DEPRECATED + RegFeeConfirms uint16 `json:"regfeeconfirms"` // DEPRECATED } // Spot is a snapshot of a market at the end of a match cycle. A slice of Spot diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index 40ffa636b2..ff95ae0591 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -177,7 +177,9 @@ cat << EOF >> "./markets.json" "configPath": "${TEST_ROOT}/dcr/alpha/dcrd.conf", "regConfs": 1, "regFee": 100000000, - "regXPub": "spubVWKGn9TGzyo7M4b5xubB5UV4joZ5HBMNBmMyGvYEaoZMkSxVG4opckpmQ26E85iHg8KQxrSVTdex56biddqtXBerG9xMN8Dvb3eNQVFFwpE" + "regXPub": "spubVWKGn9TGzyo7M4b5xubB5UV4joZ5HBMNBmMyGvYEaoZMkSxVG4opckpmQ26E85iHg8KQxrSVTdex56biddqtXBerG9xMN8Dvb3eNQVFFwpE", + "bondAmt": 1000000000, + "bondConfs": 1 }, "BTC_simnet": { "bip44symbol": "btc", diff --git a/server/account/account.go b/server/account/account.go index 97e7e19479..2dc5c4d49f 100644 --- a/server/account/account.go +++ b/server/account/account.go @@ -8,22 +8,24 @@ import ( "encoding/hex" "encoding/json" "fmt" - "time" - "decred.org/dcrdex/server/account/pki" "github.com/decred/dcrd/crypto/blake256" "github.com/decred/dcrd/dcrec/secp256k1/v4" ) -var ( - HashFunc = blake256.Sum256 - century = time.Hour * 24 * 365 * 100 -) +// PrivateKey is the private key type used by DEX. +type PrivateKey = secp256k1.PrivateKey const ( - HashSize = blake256.Size + PrivKeySize = secp256k1.PrivKeyBytesLen + PubKeySize = secp256k1.PubKeyBytesLenCompressed + HashSize = blake256.Size ) +// HashFunc is the hash function used to generate account IDs from pubkeys. +var HashFunc = blake256.Sum256 + +// AccountID is a DEX account identifier. type AccountID [HashSize]byte // NewID generates a unique account id with the provided public key bytes. @@ -74,9 +76,9 @@ type Account struct { // NewAccountFromPubKey creates a dex client account from the provided public // key bytes. func NewAccountFromPubKey(pk []byte) (*Account, error) { - if len(pk) != pki.PubKeySize { + if len(pk) != PubKeySize { return nil, fmt.Errorf("invalid pubkey length, "+ - "expected %d, got %d", pki.PubKeySize, len(pk)) + "expected %d, got %d", PubKeySize, len(pk)) } pubKey, err := secp256k1.ParsePubKey(pk) @@ -118,7 +120,6 @@ const ( // details holds rule specific details. type details struct { name, description string - duration time.Duration } // ruleDetails maps rules to rule details. @@ -126,27 +127,22 @@ var ruleDetails = map[Rule]details{ NoRule: { name: "NoRule", description: "no rules have been broken", - duration: 0, }, PreimageReveal: { name: "PreimageReveal", description: "failed to respond with a valid preimage for an order during epoch processing", - duration: century, }, FailureToAct: { name: "FailureToAct", description: "did not follow through on a swap negotiation step", - duration: century, }, CancellationRate: { name: "CancellationRate", description: "cancellation rate dropped below the acceptable level", - duration: century, }, LowFees: { name: "LowFees", description: "did not pay transaction mining fees at the requisite level", - duration: century, }, } @@ -166,14 +162,6 @@ func (r Rule) Description() string { return "description not specified" } -// Duration returns the penalty duration of the rule being broken. -func (r Rule) Duration() time.Duration { - if d, ok := ruleDetails[r]; ok { - return d.duration - } - return century -} - // Punishable returns whether breaking this rule incurs a penalty. func (r Rule) Punishable() bool { return r > NoRule && r < MaxRule diff --git a/server/account/pki/pki.go b/server/account/pki/pki.go deleted file mode 100644 index 30f19b6918..0000000000 --- a/server/account/pki/pki.go +++ /dev/null @@ -1,13 +0,0 @@ -// This code is available on the terms of the project LICENSE.md file, -// also available online at https://blueoakcouncil.org/license/1.0.0. - -package pki - -import "github.com/decred/dcrd/dcrec/secp256k1/v4" - -type PrivateKey = secp256k1.PrivateKey - -const ( - PrivKeySize = secp256k1.PrivKeyBytesLen - PubKeySize = secp256k1.PubKeyBytesLenCompressed -) diff --git a/server/admin/server_test.go b/server/admin/server_test.go index fb4bbd637c..c90cb01ea0 100644 --- a/server/admin/server_test.go +++ b/server/admin/server_test.go @@ -317,7 +317,7 @@ func TestMarkets(t *testing.T) { // No markets. w := httptest.NewRecorder() - r, _ := http.NewRequest("GET", "https://localhost/markets", nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/markets", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -342,7 +342,7 @@ func TestMarkets(t *testing.T) { core.markets["dcr_btc"] = tMkt w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/markets", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/markets", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -396,7 +396,7 @@ func TestMarkets(t *testing.T) { tMkt.persist = true w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/markets", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/markets", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -466,7 +466,7 @@ func TestMarketInfo(t *testing.T) { // Request a non-existent market. w := httptest.NewRecorder() name := "dcr_btc" - r, _ := http.NewRequest("GET", "https://localhost/market/"+name, nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/market/"+name, nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -484,7 +484,7 @@ func TestMarketInfo(t *testing.T) { // Not running market. w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name, nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name, nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -508,7 +508,7 @@ func TestMarketInfo(t *testing.T) { core.markets[name].running = true core.markets[name].suspend = &market.SuspendEpoch{Idx: 1324, End: time.Now()} w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name, nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name, nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -571,7 +571,7 @@ func TestMarketOrderBook(t *testing.T) { core.bookErr = test.bookErr tMkt.running = test.running w := httptest.NewRecorder() - r, _ := http.NewRequest("GET", "https://localhost/market/"+test.mkt+"/orderbook", nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/market/"+test.mkt+"/orderbook", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -630,7 +630,7 @@ func TestMarketEpochOrders(t *testing.T) { core.epochOrdersErr = test.epochOrdersErr tMkt.running = test.running w := httptest.NewRecorder() - r, _ := http.NewRequest("GET", "https://localhost/market/"+test.mkt+"/epochorders", nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/market/"+test.mkt+"/epochorders", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -703,7 +703,7 @@ func TestMarketMatches(t *testing.T) { core.marketMatchesErr = test.marketMatchesErr tMkt.running = test.running w := httptest.NewRecorder() - r, _ := http.NewRequest("GET", "https://localhost/market/"+test.mkt+"/matches"+test.token, nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/market/"+test.mkt+"/matches"+test.token, nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -746,7 +746,7 @@ func TestResume(t *testing.T) { // Non-existent market name := "dcr_btc" w := httptest.NewRecorder() - r, _ := http.NewRequest("GET", "https://localhost/market/"+name+"/resume", nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/resume", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -763,7 +763,7 @@ func TestResume(t *testing.T) { core.markets[name] = tMkt w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/resume", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/resume", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -779,7 +779,7 @@ func TestResume(t *testing.T) { // Now stopped. tMkt.running = false w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/resume", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/resume", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -809,7 +809,7 @@ func TestResume(t *testing.T) { // Time in past w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/resume?t=12", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/resume?t=12", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -825,7 +825,7 @@ func TestResume(t *testing.T) { // Bad suspend time (not a time) w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/resume?t=QWERT", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/resume?t=QWERT", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -854,7 +854,7 @@ func TestSuspend(t *testing.T) { // Non-existent market name := "dcr_btc" w := httptest.NewRecorder() - r, _ := http.NewRequest("GET", "https://localhost/market/"+name+"/suspend", nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/suspend", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -870,7 +870,7 @@ func TestSuspend(t *testing.T) { core.markets[name] = tMkt w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/suspend", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/suspend", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -886,7 +886,7 @@ func TestSuspend(t *testing.T) { // Now running. tMkt.running = true w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/suspend", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/suspend", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -918,7 +918,7 @@ func TestSuspend(t *testing.T) { // Specify a time in the past. w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/suspend?t=12", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/suspend?t=12", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -934,7 +934,7 @@ func TestSuspend(t *testing.T) { // Bad suspend time (not a time) w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/suspend?t=QWERT", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/suspend?t=QWERT", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -951,7 +951,7 @@ func TestSuspend(t *testing.T) { // Good suspend time, one minute in the future w = httptest.NewRecorder() tMsFuture := time.Now().Add(time.Minute).UnixMilli() - r, _ = http.NewRequest("GET", fmt.Sprintf("https://localhost/market/%v/suspend?t=%d", name, tMsFuture), nil) + r, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost/market/%v/suspend?t=%d", name, tMsFuture), nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -982,7 +982,7 @@ func TestSuspend(t *testing.T) { // persist=true (OK) w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/suspend?persist=true", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/suspend?persist=true", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -997,7 +997,7 @@ func TestSuspend(t *testing.T) { // persist=0 (OK) w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/suspend?persist=0", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/suspend?persist=0", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1012,7 +1012,7 @@ func TestSuspend(t *testing.T) { // invalid persist w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/market/"+name+"/suspend?persist=blahblahblah", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/market/"+name+"/suspend?persist=blahblahblah", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1035,7 +1035,7 @@ func TestAuthMiddleware(t *testing.T) { w.WriteHeader(http.StatusOK) })) - r, _ := http.NewRequest("GET", "", nil) + r, _ := http.NewRequest(http.MethodGet, "", nil) r.RemoteAddr = "localhost" wantAuthError := func(name string, want bool) { @@ -1098,7 +1098,7 @@ func TestAccounts(t *testing.T) { // No accounts. w := httptest.NewRecorder() - r, _ := http.NewRequest("GET", "https://localhost/accounts", nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/accounts", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1130,14 +1130,14 @@ func TestAccounts(t *testing.T) { acct := &db.Account{ AccountID: accountID, Pubkey: dex.Bytes(pubkey), + FeeAsset: 42, FeeAddress: "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k", FeeCoin: dex.Bytes(feeCoin), - BrokenRule: account.Rule(byte(255)), } core.accounts = append(core.accounts, acct) w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/accounts", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/accounts", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1150,9 +1150,9 @@ func TestAccounts(t *testing.T) { { "accountid": "0a9912205b2cbab0c25c2de30bda9074de0ae23b065489a99199bad763f102cc", "pubkey": "0204988a498d5d19514b217e872b4dbd1cf071d365c4879e64ed5919881c97eb19", + "feeasset": 42, "feeaddress": "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k", - "feecoin": "6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005", - "brokenrule": 255 + "feecoin": "6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005" } ] ` @@ -1164,7 +1164,7 @@ func TestAccounts(t *testing.T) { core.accountsErr = errors.New("error") w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/accounts", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/accounts", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1189,7 +1189,7 @@ func TestAccountInfo(t *testing.T) { // No account. w := httptest.NewRecorder() - r, _ := http.NewRequest("GET", "https://localhost/account/"+acctIDStr, nil) + r, _ := http.NewRequest(http.MethodGet, "https://localhost/account/"+acctIDStr, nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1221,13 +1221,13 @@ func TestAccountInfo(t *testing.T) { core.account = &db.Account{ AccountID: accountID, Pubkey: dex.Bytes(pubkey), + FeeAsset: 42, FeeAddress: "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k", FeeCoin: dex.Bytes(feeCoin), - BrokenRule: account.Rule(byte(255)), } w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/account/"+acctIDStr, nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/account/"+acctIDStr, nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1239,9 +1239,9 @@ func TestAccountInfo(t *testing.T) { exp := `{ "accountid": "0a9912205b2cbab0c25c2de30bda9074de0ae23b065489a99199bad763f102cc", "pubkey": "0204988a498d5d19514b217e872b4dbd1cf071d365c4879e64ed5919881c97eb19", + "feeasset": 42, "feeaddress": "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k", - "feecoin": "6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005", - "brokenrule": 255 + "feecoin": "6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005" } ` if exp != w.Body.String() { @@ -1250,7 +1250,7 @@ func TestAccountInfo(t *testing.T) { // ok, upper case account id w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/account/"+strings.ToUpper(acctIDStr), nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/account/"+strings.ToUpper(acctIDStr), nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1264,7 +1264,7 @@ func TestAccountInfo(t *testing.T) { // acct id is not hex w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/account/nothex", nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/account/nothex", nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1275,7 +1275,7 @@ func TestAccountInfo(t *testing.T) { // acct id wrong length w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/account/"+acctIDStr[2:], nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/account/"+acctIDStr[2:], nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) @@ -1288,7 +1288,7 @@ func TestAccountInfo(t *testing.T) { core.accountErr = errors.New("error") w = httptest.NewRecorder() - r, _ = http.NewRequest("GET", "https://localhost/account/"+acctIDStr, nil) + r, _ = http.NewRequest(http.MethodGet, "https://localhost/account/"+acctIDStr, nil) r.RemoteAddr = "localhost" mux.ServeHTTP(w, r) diff --git a/server/asset/common.go b/server/asset/common.go index 682e412397..7c6acdd5a9 100644 --- a/server/asset/common.go +++ b/server/asset/common.go @@ -22,7 +22,7 @@ type KeyIndexer interface { SetKeyIndex(idx uint32, xpub string) error } -// BackendInfo provides auxillary information about a backend. +// BackendInfo provides auxiliary information about a backend. type BackendInfo struct { SupportsDynamicTxFee bool } @@ -80,7 +80,7 @@ type Backend interface { // Synced should return true when the blockchain is synced and ready for // fee rate estimation. Synced() (bool, error) - // Info provides auxillary information about a backend. + // Info provides auxiliary information about a backend. Info() *BackendInfo // ValidateFeeRate checks that the transaction fees used to initiate the // contract are sufficient. diff --git a/server/asset/dcr/dcr.go b/server/asset/dcr/dcr.go index 37de3bbf49..d32596c33d 100644 --- a/server/asset/dcr/dcr.go +++ b/server/asset/dcr/dcr.go @@ -39,6 +39,8 @@ import ( // Driver implements asset.Driver. type Driver struct{} +var _ asset.Driver = (*Driver)(nil) + // Setup creates the DCR backend. Start the backend with its Run method. func (d *Driver) Setup(configPath string, logger dex.Logger, network dex.Network) (asset.Backend, error) { // With a websocket RPC client with auto-reconnect, setup a logging @@ -114,6 +116,7 @@ const ( BipID = 42 assetName = "dcr" immatureTransactionError = dex.ErrorKind("immature output") + BondVersion = 0 ) // dcrNode represents a blockchain information fetcher. In practice, it is @@ -167,7 +170,7 @@ func translateRPCCancelErr(err error) error { // script. func ParseBondTx(ver uint16, rawTx []byte) (bondCoinID []byte, amt int64, bondAddr string, bondPubKeyHash []byte, lockTime int64, acct account.AccountID, err error) { - if ver != 0 { + if ver != BondVersion { err = errors.New("only version 0 bonds supported") return } @@ -628,6 +631,74 @@ func (dcr *Backend) VerifyUnspentCoin(ctx context.Context, coinID []byte) error return nil } +// BondVer returns the latest supported bond version. +func (dcr *Backend) BondVer() uint16 { + return BondVersion +} + +// ParseBondTx makes the package-level ParseBondTx pure function accessible via +// a Backend instance. This performs basic validation of a serialized +// time-locked fidelity bond transaction given the bond's P2SH redeem script. +func (*Backend) ParseBondTx(ver uint16, rawTx []byte) (bondCoinID []byte, amt int64, bondAddr string, + bondPubKeyHash []byte, lockTime int64, acct account.AccountID, err error) { + return ParseBondTx(ver, rawTx) +} + +// BondCoin locates a bond transaction output, validates the entire transaction, +// and returns the amount, encoded lockTime and account ID, and the +// confirmations of the transaction. It is a CoinNotFoundError if the +// transaction output is spent. +func (dcr *Backend) BondCoin(ctx context.Context, ver uint16, coinID []byte) (amt, lockTime, confs int64, acct account.AccountID, err error) { + txHash, vout, errCoin := decodeCoinID(coinID) + if errCoin != nil { + err = fmt.Errorf("error decoding coin ID %x: %w", coinID, errCoin) + return + } + + verboseTx, err := dcr.node.GetRawTransactionVerbose(dcr.ctx, txHash) + if err != nil { + if isTxNotFoundErr(err) { + err = asset.CoinNotFoundError + } else { + err = translateRPCCancelErr(err) + } + return + } + + if int(vout) > len(verboseTx.Vout)-1 { + err = fmt.Errorf("invalid output index for tx with %d outputs", len(verboseTx.Vout)) + return + } + + confs = verboseTx.Confirmations + + // msgTx, err := msgTxFromHex(verboseTx.Hex) + rawTx, err := hex.DecodeString(verboseTx.Hex) // ParseBondTx will deserialize to msgTx, so just get the bytes + if err != nil { + err = fmt.Errorf("failed to decode transaction %s: %w", txHash, err) + return + } + // rawTx, _ := msgTx.Bytes() + + // tree := determineTxTree(msgTx) + txOut, err := dcr.node.GetTxOut(ctx, txHash, vout, wire.TxTreeRegular, true) // check regular tree first + if err == nil && txOut == nil { + txOut, err = dcr.node.GetTxOut(ctx, txHash, vout, wire.TxTreeStake, true) // check stake tree + } + if err != nil { + err = fmt.Errorf("GetTxOut error for output %s:%d: %w", + txHash, vout, translateRPCCancelErr(err)) + return + } + if txOut == nil { // spent == invalid bond + err = asset.CoinNotFoundError + return + } + + _, amt, _, _, lockTime, acct, err = ParseBondTx(ver, rawTx) + return +} + // FeeCoin gets the recipient address, value, and confirmations of a transaction // output encoded by the given coinID. A non-nil error is returned if the // output's pubkey script is not a non-stake P2PKH requiring a single diff --git a/server/auth/auth.go b/server/auth/auth.go index 0aa4fb5c5e..5067025632 100644 --- a/server/auth/auth.go +++ b/server/auth/auth.go @@ -4,6 +4,7 @@ package auth import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -20,6 +21,7 @@ import ( "decred.org/dcrdex/server/asset" "decred.org/dcrdex/server/comms" "decred.org/dcrdex/server/db" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" ) @@ -43,25 +45,29 @@ func unixMsNow() time.Time { // Storage updates and fetches account-related data from what is presumably a // database. type Storage interface { - // CloseAccount closes the account for violation of the specified rule. - CloseAccount(account.AccountID, account.Rule) error - // RestoreAccount opens an account that was closed by CloseAccount. - RestoreAccount(account.AccountID) error - ForgiveMatchFail(mid order.MatchID) (bool, error) - // Account retrieves account info for the specified account ID. - Account(account.AccountID) (acct *account.Account, paid, open bool) + // Account retrieves account info for the specified account ID and lock time + // threshold, which determines when a bond is considered expired. + Account(account.AccountID, time.Time) (acct *account.Account, bonds []*db.Bond, legacy, legacyPaid bool) + + CreateAccountWithBond(acct *account.Account, bond *db.Bond) error + AddBond(acct account.AccountID, bond *db.Bond) error + DeleteBond(assetID uint32, coinID []byte) error + + CreateAccount(acct *account.Account, feeAsset uint32, feeAddr string) error // DEPRECATED + AccountRegAddr(account.AccountID) (addr string, asset uint32, err error) // DEPRECATED + PayAccount(account.AccountID, []byte) error // DEPRECATED + AccountInfo(aid account.AccountID) (*db.Account, error) + UserOrderStatuses(aid account.AccountID, base, quote uint32, oids []order.OrderID) ([]*db.OrderStatus, error) ActiveUserOrderStatuses(aid account.AccountID) ([]*db.OrderStatus, error) CompletedUserOrders(aid account.AccountID, N int) (oids []order.OrderID, compTimes []int64, err error) ExecutedCancelsForUser(aid account.AccountID, N int) ([]*db.CancelRecord, error) CompletedAndAtFaultMatchStats(aid account.AccountID, lastN int) ([]*db.MatchOutcome, error) + ForgiveMatchFail(mid order.MatchID) (bool, error) PreimageStats(user account.AccountID, lastN int) ([]*db.PreimageResult, error) AllActiveUserMatches(aid account.AccountID) ([]*db.MatchData, error) MatchStatuses(aid account.AccountID, base, quote uint32, matchIDs []order.MatchID) ([]*db.MatchStatus, error) - CreateAccount(acct *account.Account, feeAsset uint32, feeAddr string) error - AccountRegAddr(account.AccountID) (addr string, asset uint32, err error) - PayAccount(account.AccountID, []byte) error } // Signer signs messages. The message must be a 32-byte hash. @@ -70,10 +76,29 @@ type Signer interface { PubKey() *secp256k1.PublicKey } -// FeeChecker is a function for retrieving the details for a fee payment. +// FeeChecker is a function for retrieving the details for a fee payment txn. type FeeChecker func(assetID uint32, coinID []byte) (addr string, val uint64, confs int64, err error) -type TxDataSource func([]byte) ([]byte, error) +// BondCoinChecker is a function for locating an unspent bond, and extracting +// the amount, lockTime, and account ID. The confirmations of the bond +// transaction are also provided. +type BondCoinChecker func(ctx context.Context, assetID uint32, ver uint16, + coinID []byte) (amt, lockTime, confs int64, acct account.AccountID, err error) + +// BondTxParser parses a dex fidelity bond transaction and the redeem script of +// the first output of the transaction, which must be the actual bond output. +// The returned account ID is from the second output. This will become a +// multi-asset checker. +// +// NOTE: For DCR, and possibly all assets, the bond script is reconstructed from +// the null data output, and it is verified that the bond output pays to this +// script. As such, there is no provided bondData (redeem script for UTXO +// assets), but this may need for other assets. +type BondTxParser func(assetID uint32, ver uint16, rawTx []byte) (bondCoinID []byte, + amt int64, lockTime int64, acct account.AccountID, err error) + +// TxDataSource retrieves the raw transaction for a coin ID. +type TxDataSource func(coinID []byte) (rawTx []byte, err error) // A respHandler is the handler for the response to a DEX-originating request. A // respHandler has a time associated with it so that old unused handlers can be @@ -86,49 +111,65 @@ type respHandler struct { // clientInfo represents a DEX client, including account information and last // known comms.Link. type clientInfo struct { + acct *account.Account + conn comms.Link + mtx sync.Mutex - acct *account.Account - conn comms.Link respHandlers map[uint64]*respHandler - recentOrders *latestOrders - suspended bool // penalized, disallow new orders -} + tier int64 + bonds []*db.Bond // only confirmed and active, not pending -// GraceLimit returns the number of initial orders allowed for a new user before -// the cancellation rate threshold is enforced. -func (auth *AuthManager) GraceLimit() int { - // Grace period if: total/(1+total) <= thresh OR total <= thresh/(1-thresh). - return int(math.Round(1e8*auth.cancelThresh/(1-auth.cancelThresh))) / 1e8 + legacyFeePaid bool // deprecated with bonds } -func (auth *AuthManager) checkCancelRate(client *clientInfo) (cancels, completions int, rate float64, penalize bool) { - var total int - total, cancels = client.recentOrders.counts() - completions = total - cancels - rate = float64(cancels) / float64(total) // rate will be NaN if total is 0 - penalize = rate > auth.cancelThresh && // NaN cancelRate compares false - total > auth.GraceLimit() - log.Tracef("User %v cancellation rate is now %.2f%% (%d cancels : %d successes). Violation = %v", client.acct.ID, - 100*rate, cancels, completions, penalize) +// not thread-safe +func (client *clientInfo) bondTier() (bondTier int64) { + for _, bi := range client.bonds { + bondTier += int64(bi.Strength) + } return } -func (client *clientInfo) suspend() { - client.mtx.Lock() - client.suspended = true - client.mtx.Unlock() -} +// not thread-safe +func (client *clientInfo) addBond(bond *db.Bond) (bondTier int64) { + var dup bool + for _, bi := range client.bonds { + bondTier += int64(bi.Strength) + dup = dup || (bi.AssetID == bond.AssetID && bytes.Equal(bi.CoinID, bond.CoinID)) + } -func (client *clientInfo) restore() { - client.mtx.Lock() - client.suspended = false - client.mtx.Unlock() + if !dup { // idempotent + client.bonds = append(client.bonds, bond) + bondTier += int64(bond.Strength) + } + + return } -func (client *clientInfo) isSuspended() bool { - client.mtx.Lock() - defer client.mtx.Unlock() - return client.suspended +// not thread-safe +func (client *clientInfo) pruneBonds(lockTimeThresh int64) (pruned []*db.Bond, bondTier int64) { + if len(client.bonds) == 0 { + return + } + + var n int + for _, bond := range client.bonds { + if bond.LockTime >= lockTimeThresh { // not expired + if len(pruned) > 0 /* n < i */ { // a prior bond was removed, must move this element up in the slice + client.bonds[n] = bond + } + n++ + bondTier += int64(bond.Strength) + continue + } + log.Infof("Expiring user %v bond %v (%s)", client.acct.ID, + coinIDString(bond.AssetID, bond.CoinID), dex.BipIDSymbol(bond.AssetID)) + pruned = append(pruned, bond) + // n not incremented, next live bond shifts up + } + client.bonds = client.bonds[:n] // no-op if none expired + + return } func (client *clientInfo) rmHandler(id uint64) bool { @@ -185,16 +226,21 @@ func (client *clientInfo) respHandler(id uint64) *respHandler { // signing messages with the DEX's private key. AuthManager manages requests to // the 'connect' route. type AuthManager struct { + wg sync.WaitGroup storage Storage signer Signer - checkFee FeeChecker + checkFee FeeChecker // legacy fee confs, amt, and address + parseBondTx BondTxParser + checkBond BondCoinChecker // fidelity bond amount, lockTime, acct, and confs miaUserTimeout time.Duration unbookFun func(account.AccountID) - feeAddress func(assetID uint32) string - feeAssets map[uint32]*msgjson.FeeAsset + bondExpiry time.Duration // a bond is expired when time.Until(lockTime) < bondExpiry + bondAssets map[uint32]*msgjson.BondAsset + + feeAddress func(assetID uint32) string // DEPRECATED (V0PURGE) + feeAssets map[uint32]*msgjson.FeeAsset // DEPRECATED (V0PURGE) - anarchy bool freeCancels bool banScore uint32 cancelThresh float64 @@ -204,8 +250,11 @@ type AuthManager struct { // latencyQ is a queue for fee coin waiters to deal with latency. latencyQ *wait.TickerQueue - feeWaiterMtx sync.Mutex - feeWaiterIdx map[account.AccountID]struct{} + feeWaiterMtx sync.Mutex // DEPRECATED (V0PURGE) + feeWaiterIdx map[account.AccountID]struct{} // DEPRECATED + + bondWaiterMtx sync.Mutex + bondWaiterIdx map[string]struct{} connMtx sync.RWMutex users map[account.AccountID]*clientInfo @@ -215,6 +264,7 @@ type AuthManager struct { violationMtx sync.Mutex matchOutcomes map[account.AccountID]*latestMatchOutcomes preimgOutcomes map[account.AccountID]*latestPreimageOutcomes + orderOutcomes map[account.AccountID]*latestOrders // cancel/complete, was in clientInfo.recentOrders txDataSources map[uint32]TxDataSource } @@ -230,6 +280,9 @@ const ( noRedeemAsMakerScore = 7 // taker has contract stuck for 8 hrs noRedeemAsTakerScore = 1 // just dumb, counterparty not inconvenienced + // cancel rate exceeds threshold + excessiveCancels = 5 + successScore = -1 // offsets the violations defaultBanScore = 20 @@ -248,6 +301,7 @@ const ( ViolationNoSwapAsTaker ViolationNoRedeemAsMaker ViolationNoRedeemAsTaker + ViolationCancelRate ) var violations = map[Violation]struct { @@ -261,6 +315,7 @@ var violations = map[Violation]struct { ViolationNoSwapAsTaker: {noSwapAsTakerScore, "no swap as taker"}, ViolationNoRedeemAsMaker: {noRedeemAsMakerScore, "no redeem as maker"}, ViolationNoRedeemAsTaker: {noRedeemAsTakerScore, "no redeem as taker"}, + ViolationCancelRate: {excessiveCancels, "excessive cancels"}, ViolationInvalid: {0, "invalid violation"}, } @@ -320,6 +375,18 @@ type Config struct { // satisfied by a secp256k1.PrivateKey. Signer Signer + // BondExpiry is the time in seconds left until a bond's LockTime is reached + // that defines when a bond is considered expired. + BondExpiry uint64 + // BondAssets indicates the supported bond assets and parameters. + BondAssets map[string]*msgjson.BondAsset + // BondTxParser performs rudimentary validation of a raw time-locked + // fidelity bond transaction. e.g. dcr.ParseBondTx + BondTxParser BondTxParser + // BondChecker locates an unspent bond, and extracts the amount, lockTime, + // and account ID, plus txn confirmations. + BondChecker BondCoinChecker + // FeeAddress retrieves a fresh registration fee address for an asset. It // should return an empty string for an unsupported asset. FeeAddress func(assetID uint32) string @@ -331,6 +398,7 @@ type Config struct { // TxDataSources are sources of tx data for a coin ID. TxDataSources map[uint32]TxDataSource + // UserUnbooker is a function for unbooking all of a user's orders. UserUnbooker func(account.AccountID) // MiaUserTimeout is how long after a user disconnects until UserUnbooker is @@ -338,7 +406,6 @@ type Config struct { MiaUserTimeout time.Duration CancelThreshold float64 - Anarchy bool FreeCancels bool // BanScore defines the penalty score when an account gets closed. BanScore uint32 @@ -371,21 +438,29 @@ func NewAuthManager(cfg *Config) *AuthManager { if absTakerLotLimit == 0 { absTakerLotLimit = defaultAbsTakerLotLimit } - // Re-key the map for efficiency in AuthManager methods. - feeAssets := make(map[uint32]*msgjson.FeeAsset) + // Re-key the maps for efficiency in AuthManager methods. + feeAssets := make(map[uint32]*msgjson.FeeAsset, len(cfg.FeeAssets)) for _, asset := range cfg.FeeAssets { feeAssets[asset.ID] = asset } + bondAssets := make(map[uint32]*msgjson.BondAsset, len(cfg.BondAssets)) + for _, asset := range cfg.BondAssets { + bondAssets[asset.ID] = asset + } + auth := &AuthManager{ storage: cfg.Storage, signer: cfg.Signer, - checkFee: cfg.FeeChecker, + bondAssets: bondAssets, + bondExpiry: time.Duration(cfg.BondExpiry) * time.Second, + checkFee: cfg.FeeChecker, // e.g. dcr's FeeCoin + parseBondTx: cfg.BondTxParser, // e.g. dcr's ParseBondTx + checkBond: cfg.BondChecker, // e.g. dcr's BondCoin miaUserTimeout: cfg.MiaUserTimeout, unbookFun: cfg.UserUnbooker, feeAddress: cfg.FeeAddress, feeAssets: feeAssets, - anarchy: cfg.Anarchy, - freeCancels: cfg.FreeCancels || cfg.Anarchy, + freeCancels: cfg.FreeCancels, banScore: banScore, cancelThresh: cfg.CancelThreshold, initTakerLotLimit: initTakerLotLimit, @@ -395,14 +470,18 @@ func NewAuthManager(cfg *Config) *AuthManager { conns: make(map[uint64]*clientInfo), unbookers: make(map[account.AccountID]*time.Timer), feeWaiterIdx: make(map[account.AccountID]struct{}), + bondWaiterIdx: make(map[string]struct{}), matchOutcomes: make(map[account.AccountID]*latestMatchOutcomes), preimgOutcomes: make(map[account.AccountID]*latestPreimageOutcomes), + orderOutcomes: make(map[account.AccountID]*latestOrders), txDataSources: cfg.TxDataSources, } comms.Route(msgjson.ConnectRoute, auth.handleConnect) - comms.Route(msgjson.RegisterRoute, auth.handleRegister) - comms.Route(msgjson.NotifyFeeRoute, auth.handleNotifyFee) + comms.Route(msgjson.RegisterRoute, auth.handleRegister) // DEPRECATED (V0PURGE) + comms.Route(msgjson.NotifyFeeRoute, auth.handleNotifyFee) // DEPRECATED (V0PURGE) + comms.Route(msgjson.PostBondRoute, auth.handlePostBond) + comms.Route(msgjson.PreValidateBondRoute, auth.handlePreValidateBond) comms.Route(msgjson.MatchStatusRoute, auth.handleMatchStatus) comms.Route(msgjson.OrderStatusRoute, auth.handleOrderStatus) return auth @@ -431,55 +510,78 @@ func (auth *AuthManager) ExpectUsers(users map[account.AccountID]struct{}, withi } } +// GraceLimit returns the number of initial orders allowed for a new user before +// the cancellation rate threshold is enforced. +func (auth *AuthManager) GraceLimit() int { + // Grace period if: total/(1+total) <= thresh OR total <= thresh/(1-thresh). + return int(math.Round(1e8*auth.cancelThresh/(1-auth.cancelThresh))) / 1e8 +} + // RecordCancel records a user's executed cancel order, including the canceled // order ID, and the time when the cancel was executed. func (auth *AuthManager) RecordCancel(user account.AccountID, oid, target order.OrderID, epochGap int32, t time.Time) { - auth.recordOrderDone(user, oid, &target, epochGap, t.UnixMilli()) + score := auth.recordOrderDone(user, oid, &target, epochGap, t.UnixMilli()) + + tier, bondTier, changed := auth.computeUserTier(user, score) + log.Debugf("RecordCancel: user %v strikes %d, bond tier %v => trading tier %v", + user, score, bondTier, tier) + // If their tier sinks below 1, unbook their orders and send a note. + if tier < 1 { + details := fmt.Sprintf("excessive cancellation rate, new tier = %d", tier) + auth.Penalize(user, account.CancellationRate, details) + } + if changed { + go auth.sendTierChanged(user, tier, "excessive, cancellation rate") + } } // RecordCompletedOrder records a user's completed order, where completed means // a swap involving the order was successfully completed and the order is no // longer on the books if it ever was. func (auth *AuthManager) RecordCompletedOrder(user account.AccountID, oid order.OrderID, t time.Time) { - auth.recordOrderDone(user, oid, nil, db.EpochGapNA, t.UnixMilli()) + score := auth.recordOrderDone(user, oid, nil, db.EpochGapNA, t.UnixMilli()) + tier, bondTier, changed := auth.computeUserTier(user, score) // may raise tier + if changed { + log.Tracef("RecordCompletedOrder: tier changed for user %v strikes %d, bond tier %v => trading tier %v", + user, score, bondTier, tier) + go auth.sendTierChanged(user, tier, "successful order completion") + } } -// recordOrderDone an order that has finished processing. This can be a cancel -// order, which matched and unbooked another order, or a trade order that +// recordOrderDone records that an order has finished processing. This can be a +// cancel order, which matched and unbooked another order, or a trade order that // completed the swap negotiation. Note that in the case of a cancel, oid refers // to the ID of the cancel order itself, while target is non-nil for cancel -// orders. -func (auth *AuthManager) recordOrderDone(user account.AccountID, oid order.OrderID, target *order.OrderID, epochGap int32, tMS int64) { - client := auth.user(user) - if client == nil { - // It is likely that the user is gone if this is a revoked order. - // Regardless, connect will rebuild client's recentOrders from the DB. +// orders. The user's new score is returned, which can be used to compute the +// user's tier with computeUserTier. +func (auth *AuthManager) recordOrderDone(user account.AccountID, oid order.OrderID, target *order.OrderID, epochGap int32, tMS int64) (score int32) { + auth.violationMtx.Lock() + if orderOutcomes, found := auth.orderOutcomes[user]; found { + orderOutcomes.add(&oidStamped{ + OrderID: oid, + time: tMS, + target: target, + epochGap: epochGap, + }) + score = auth.userScore(user) + auth.violationMtx.Unlock() + log.Debugf("Recorded order %v that has finished processing: user=%v, time=%v, target=%v", + oid, user, tMS, target) return } + auth.violationMtx.Unlock() - // Update recent orders and check/set suspended status atomically. - client.mtx.Lock() - client.recentOrders.add(&oidStamped{ - OrderID: oid, - time: tMS, - target: target, - epochGap: epochGap, - }) - - log.Debugf("Recorded order %v that has finished processing: user=%v, time=%v, target=%v", - oid, user, tMS, target) - - // Recompute cancellation and penalize violation. - cancels, completions, rate, penalize := auth.checkCancelRate(client) - client.mtx.Unlock() - if penalize && !auth.freeCancels && !client.isSuspended() { - details := fmt.Sprintf("Suspending user %v for exceeding the cancellation rate threshold %.2f%%. "+ - "(%d cancels : %d successes => %.2f%% )", user, auth.cancelThresh, cancels, completions, 100*rate) - log.Info(details) - if err := auth.Penalize(user, account.CancellationRate, details); err != nil { - log.Errorf("Failed to penalize user %v: %v", user, err) - } + // The user is currently not connected and authenticated. When the user logs + // back in, their history will be reloaded (loadUserScore) and their tier + // recomputed, but compute their score now from DB for the caller. + var err error + score, err = auth.loadUserScore(user) + if err != nil { + log.Errorf("Failed to load order and match outcomes for user %v: %v", user, err) + return 0 } + + return } // Run runs the AuthManager until the context is canceled. Satisfies the @@ -488,7 +590,28 @@ func (auth *AuthManager) Run(ctx context.Context) { log.Infof("Allowing %d settling + taker order lots per market for new users.", auth.initTakerLotLimit) log.Infof("Allowing up to %d settling + taker order lots per market for established users.", auth.absTakerLotLimit) - go auth.latencyQ.Run(ctx) + auth.wg.Add(1) + go func() { + defer auth.wg.Done() + t := time.NewTicker(20 * time.Second) + defer t.Stop() + + for { + select { + case <-t.C: + auth.checkBonds() + case <-ctx.Done(): + return + } + } + }() + + auth.wg.Add(1) + go func() { + defer auth.wg.Done() + auth.latencyQ.Run(ctx) + }() + <-ctx.Done() auth.connMtx.Lock() defer auth.connMtx.Unlock() @@ -496,7 +619,10 @@ func (auth *AuthManager) Run(ctx context.Context) { ub.Stop() delete(auth.unbookers, user) } - // TODO: wait for latencyQ and running comms route handlers (handleRegister, handleNotifyFee)! + + // Wait for latencyQ and checkBonds. + auth.wg.Wait() + // TODO: wait for running comms route handlers and other DB writers. } // Route wraps the comms.Route function, storing the response handler with the @@ -676,7 +802,7 @@ func (auth *AuthManager) UserSettlingLimit(user account.AccountID, mkt *dex.Mark return limit } -func integrateOutcomes(matchOutcomes *latestMatchOutcomes, preimgOutcomes *latestPreimageOutcomes) (score, successCount, piMissCount int32) { +func (auth *AuthManager) integrateOutcomes(matchOutcomes *latestMatchOutcomes, preimgOutcomes *latestPreimageOutcomes, orderOutcomes *latestOrders) (score, successCount, piMissCount int32) { if matchOutcomes != nil { matchCounts := matchOutcomes.binViolations() for v, count := range matchCounts { @@ -688,25 +814,105 @@ func integrateOutcomes(matchOutcomes *latestMatchOutcomes, preimgOutcomes *lates piMissCount = preimgOutcomes.misses() score += ViolationPreimageMiss.Score() * piMissCount } + if !auth.freeCancels { + totalOrds, cancels := orderOutcomes.counts() // completions := totalOrds - cancels + if totalOrds > auth.GraceLimit() { + cancelRate := float64(cancels) / float64(totalOrds) + if cancelRate > auth.cancelThresh { + score += ViolationCancelRate.Score() + } + } + } return } -// userScore computes an authenticated user's score from their recent match -// outcomes and preimage history. They must have entries in the outcome maps. -// Use loadUserScore to compute score from history in DB. This must be called -// with the violationMtx locked. +// userScore computes an authenticated user's score from their recent order and +// match outcomes. They must have entries in the outcome maps. Use loadUserScore +// to compute score from history in DB. This must be called with the +// violationMtx locked. func (auth *AuthManager) userScore(user account.AccountID) (score int32) { - score, _, _ = integrateOutcomes(auth.matchOutcomes[user], auth.preimgOutcomes[user]) + score, _, _ = auth.integrateOutcomes(auth.matchOutcomes[user], auth.preimgOutcomes[user], auth.orderOutcomes[user]) return score } +func (auth *AuthManager) UserScore(user account.AccountID) (score int32) { + auth.violationMtx.Lock() + if _, found := auth.matchOutcomes[user]; found { + score = auth.userScore(user) + auth.violationMtx.Unlock() + return + } + auth.violationMtx.Unlock() + + // The user is currently not connected and authenticated. When the user logs + // back in, their history will be reloaded (loadUserScore) and their tier + // recomputed, but compute their score now from DB for the caller. + var err error + score, err = auth.loadUserScore(user) + if err != nil { + log.Errorf("Failed to load order and match outcomes for user %v: %v", user, err) + return 0 + } + return +} + +// tier computes a user's tier from their conduct score and bond tier. +func (auth *AuthManager) tier(bondTier int64, score int32, legacyFeePaid bool) int64 { + tierAdj := int64(score) / int64(auth.banScore) + if legacyFeePaid { + bondTier++ + } + return bondTier - tierAdj +} + +// computeUserTier computes the user's tier given the provided score weighed +// against known active bonds. Note that bondTier is not a specific asset, and +// is just for logging, and it may be removed or changed to a map by asset ID. +// For online users, this will also indicate if the tier changed; this will +// always return false for offline users. +func (auth *AuthManager) computeUserTier(user account.AccountID, score int32) (tier, bondTier int64, changed bool) { + client := auth.user(user) + if client == nil { + // Offline. Load active bonds and legacyFeePaid flag from DB. + lockTimeThresh := time.Now().Add(auth.bondExpiry) + _, bonds, _, legacyFeePaid := auth.storage.Account(user, lockTimeThresh) + for _, bond := range bonds { + bondTier += int64(bond.Strength) + } + tier = auth.tier(bondTier, score, legacyFeePaid) + return + } + + client.mtx.Lock() + defer client.mtx.Unlock() + wasTier := client.tier + bondTier = client.bondTier() + client.tier = auth.tier(bondTier, score, client.legacyFeePaid) + tier = client.tier + changed = wasTier != tier + + return +} + +// ComputeUserTier computes the user's tier from their active bonds and conduct +// score. The bondTier is also returned. The DB is always consulted for +// computing the conduct score. Summing bond amounts may access the DB if the +// user is not presently connected. The tier for an unknown user is -1. +func (auth *AuthManager) ComputeUserTier(user account.AccountID) (tier, bondTier int64) { + score, err := auth.loadUserScore(user) + if err != nil { + log.Errorf("failed to load user score: %v", err) + return -1, -1 + } + tier, bondTier, _ = auth.computeUserTier(user, score) + return +} + func (auth *AuthManager) registerMatchOutcome(user account.AccountID, misstep NoActionStep, mmid db.MarketMatchID, value uint64, refTime time.Time) (score int32) { violation := misstep.Violation() auth.violationMtx.Lock() - defer auth.violationMtx.Unlock() - matchOutcomes, found := auth.matchOutcomes[user] - if found { + if matchOutcomes, found := auth.matchOutcomes[user]; found { matchOutcomes.add(&matchOutcome{ time: refTime.UnixMilli(), mid: mmid.MatchID, @@ -716,32 +922,20 @@ func (auth *AuthManager) registerMatchOutcome(user account.AccountID, misstep No quote: mmid.Quote, }) score = auth.userScore(user) - log.Debugf("Registering outcome %q (badness %d) for user %v, new score = %d", - violation.String(), violation.Score(), user, score) + auth.violationMtx.Unlock() return } + auth.violationMtx.Unlock() // The user is currently not connected and authenticated. When the user logs - // back in, their history will be reloaded (loadUserScore) and their account - // will be suspended/restored as required, but compute their score now from - // DB so their orders may be unbooked if need. - matchOutcomes, piOutcomes, err := auth.loadUserOutcomes(user) + // back in, their history will be reloaded (loadUserScore) and their tier + // recomputed, but compute their score now from DB for the caller. + score, err := auth.loadUserScore(user) if err != nil { - log.Errorf("Failed to load swap and preimage outcomes for user %v: %v", user, err) + log.Errorf("Failed to load order and match outcomes for user %v: %v", user, err) return 0 } - // Make outcome entries for the user to optimize subsequent outcomes calls - // while they are disconnected? This could lead to adding duplicate outcomes - // with a concurrent connect/login or subsequent outcomes while offline. - // - // auth.matchOutcomes[user] = matchOutcomes - // auth.preimgOutcomes[user] = piOutcomes - - score, _, _ = integrateOutcomes(matchOutcomes, piOutcomes) - log.Debugf("Registering outcome %q (badness %d) for user %v (offline), current score = %d", - violation.String(), violation.Score(), user, score) - return } @@ -749,7 +943,15 @@ func (auth *AuthManager) registerMatchOutcome(user account.AccountID, misstep No // TODO: provide lots instead of value, or convert to lots somehow. But, Swapper // has no clue about lot size, and neither does DB! func (auth *AuthManager) SwapSuccess(user account.AccountID, mmid db.MarketMatchID, value uint64, redeemTime time.Time) { - auth.registerMatchOutcome(user, SwapSuccess, mmid, value, redeemTime) + score := auth.registerMatchOutcome(user, SwapSuccess, mmid, value, redeemTime) + tier, bondTier, changed := auth.computeUserTier(user, score) // may raise tier + log.Debugf("Match success for user %v: strikes %d, bond tier %v => tier %v", + user, score, bondTier, tier) + if changed { + log.Infof("SwapSuccess: tier change for user %v, strikes %d, bond tier %v => trading tier %v", + user, score, bondTier, tier) + go auth.sendTierChanged(user, tier, "successful swap completion") + } } // Inaction registers an inaction violation by the user at the given step. The @@ -761,24 +963,31 @@ func (auth *AuthManager) SwapSuccess(user account.AccountID, mmid db.MarketMatch // TODO: provide lots instead of value, or convert to lots somehow. But, Swapper // has no clue about lot size, and neither does DB! func (auth *AuthManager) Inaction(user account.AccountID, misstep NoActionStep, mmid db.MarketMatchID, matchValue uint64, refTime time.Time, oid order.OrderID) { - if misstep.Violation() == ViolationInvalid { + violation := SwapSuccess.Violation() + if violation == ViolationInvalid { log.Errorf("Invalid inaction step %d", misstep) return } score := auth.registerMatchOutcome(user, misstep, mmid, matchValue, refTime) - if score < int32(auth.banScore) { - return + + // Recompute tier. + tier, bondTier, changed := auth.computeUserTier(user, score) + log.Infof("Match failure for user %v: %q (badness %v), strikes %d, bond tier %v => trading tier %v", + user, violation, violation.Score(), score, bondTier, tier) + // If their tier sinks below 1, unbook their orders and send a note. + if tier < 1 { + details := fmt.Sprintf("swap %v failure (%v) for order %v, new tier = %d", + mmid.MatchID, misstep, oid, tier) + auth.Penalize(user, account.FailureToAct, details) } - log.Debugf("User %v ban score %d is at or above %d. Penalizing.", user, score, auth.banScore) - details := fmt.Sprintf("swap %v failure (%v) for order %v", mmid.MatchID, misstep, oid) - if err := auth.Penalize(user, account.FailureToAct, details); err != nil { - log.Errorf("Failed to penalize user %v: %v", user, err) + if changed { + reason := fmt.Sprintf("swap failure for match %v order %v: %v", mmid.MatchID, oid, misstep) + go auth.sendTierChanged(user, tier, reason) } } func (auth *AuthManager) registerPreimageOutcome(user account.AccountID, miss bool, oid order.OrderID, refTime time.Time) (score int32) { auth.violationMtx.Lock() - defer auth.violationMtx.Unlock() piOutcomes, found := auth.preimgOutcomes[user] if found { piOutcomes.add(&preimageOutcome{ @@ -787,42 +996,28 @@ func (auth *AuthManager) registerPreimageOutcome(user account.AccountID, miss bo miss: miss, }) score = auth.userScore(user) - if miss { - log.Debugf("Registering outcome %q (badness %d) for user %v, new score = %d", - ViolationPreimageMiss.String(), ViolationPreimageMiss.Score(), user, score) - } + auth.violationMtx.Unlock() return } + auth.violationMtx.Unlock() // The user is currently not connected and authenticated. When the user logs - // back in, their history will be reloaded (loadUserScore) and their account - // will be suspended/restored as required, but compute their score now from - // DB so their orders may be unbooked if need. - matchOutcomes, piOutcomes, err := auth.loadUserOutcomes(user) + // back in, their history will be reloaded (loadUserScore) and their tier + // recomputed, but compute their score now from DB for the caller. + var err error + score, err = auth.loadUserScore(user) if err != nil { - log.Errorf("Failed to load swap and preimage outcomes for user %v: %v", user, err) + log.Errorf("Failed to load order and match outcomes for user %v: %v", user, err) return 0 } - // Make outcome entries for the user to optimize subsequent outcomes calls - // while they are disconnected? This could lead to adding duplicate outcomes - // with a concurrent connect/login or subsequent outcomes while offline. - // - // auth.matchOutcomes[user] = matchOutcomes - // auth.preimgOutcomes[user] = piOutcomes - - score, _, _ = integrateOutcomes(matchOutcomes, piOutcomes) - if miss { - log.Debugf("Registering outcome %q (badness %d) for user %v (offline), current score = %d", - ViolationPreimageMiss.String(), ViolationPreimageMiss.Score(), user, score) - } - return } // PreimageSuccess registers an accepted preimage for the user. func (auth *AuthManager) PreimageSuccess(user account.AccountID, epochEnd time.Time, oid order.OrderID) { - auth.registerPreimageOutcome(user, false, oid, epochEnd) + score := auth.registerPreimageOutcome(user, false, oid, epochEnd) + auth.computeUserTier(user, score) // may raise tier, but no action needed } // MissedPreimage registers a missed preimage violation by the user. @@ -831,44 +1026,39 @@ func (auth *AuthManager) MissedPreimage(user account.AccountID, epochEnd time.Ti if score < int32(auth.banScore) { return } - log.Debugf("User %v ban score %d is at or above %d. Penalizing.", user, score, auth.banScore) - details := fmt.Sprintf("preimage for order %v not provided upon request", oid) - if err := auth.Penalize(user, account.PreimageReveal, details); err != nil { - log.Errorf("Failed to penalize user %v: %v", user, err) + + // Recompute tier. + tier, bondTier, changed := auth.computeUserTier(user, score) + log.Debugf("MissedPreimage: user %v strikes %d, bond tier %v => trading tier %v", user, score, bondTier, tier) + // If their tier sinks below 1, unbook their orders and send a note. + if tier < 1 { + details := fmt.Sprintf("preimage for order %v not provided upon request: new tier = %d", oid, tier) + auth.Penalize(user, account.PreimageReveal, details) + } + if changed { + reason := fmt.Sprintf("preimage not provided upon request for order %v", oid) + go auth.sendTierChanged(user, tier, reason) } } -// Penalize closes the user's account, unbooks all of their orders, and notifies -// them of this action while citing the provided rule that corresponds to their -// most recent infraction. -func (auth *AuthManager) Penalize(user account.AccountID, lastRule account.Rule, extraDetails string) error { - if !auth.anarchy { - // If the user is connected, flag the client as suspended. - client := auth.user(user) - if client != nil { - client.suspend() - } - - // Unbook all of the user's orders across all markets. - auth.unbookUserOrders(user) +// Penalize unbooks all of their orders, and notifies them of this action while +// citing the provided rule that corresponds to their most recent infraction. +// This method is to be used when a user's tier drops below 1. +// NOTE: There is now a 'tierchange' route for *any* tier change, but this +// method still handles unbooking of the user's orders. +func (auth *AuthManager) Penalize(user account.AccountID, lastRule account.Rule, extraDetails string) { + // Unbook all of the user's orders across all markets. + auth.unbookUserOrders(user) - // Market the account as closed in the DB. - // TODO: option to close permanently or suspend for a certain time. - if err := auth.storage.CloseAccount(user /*client.acct.ID*/, lastRule); err != nil { - return err - } - } + log.Debugf("User %v account penalized. Last rule broken = %v. Detail: %s", user, lastRule, extraDetails) // Notify user of penalty. - details := "Ordering has been suspended for this account. Contact the exchange operator to reinstate privileges." - if auth.anarchy { - details = "You were penalized but the penalty will not be counted against you." - } + details := "Ordering has been suspended for this account. Post additional bond to offset violations." details = fmt.Sprintf("%s\nLast Broken Rule Details: %s\n%s", details, lastRule.Description(), extraDetails) penalty := &msgjson.Penalty{ Rule: lastRule, Time: uint64(time.Now().UnixMilli()), - Duration: uint64(lastRule.Duration().Milliseconds()), + Duration: math.MaxUint32, // deprecated Details: details, } penaltyNote := &msgjson.PenaltyNote{ @@ -877,45 +1067,33 @@ func (auth *AuthManager) Penalize(user account.AccountID, lastRule account.Rule, penaltyNote.Sig = auth.SignMsg(penaltyNote.Serialize()) note, err := msgjson.NewNotification(msgjson.PenaltyRoute, penaltyNote) if err != nil { - return fmt.Errorf("error creating penalty notification: %w", err) + log.Errorf("error creating penalty notification: %w", err) + return } - // TODO: verify that we are not sending a note over max uint16 as it - // cannot be sent over ws. auth.Notify(user, note) - if auth.anarchy { - err := fmt.Errorf("user %v penalized for rule %v, but not enforcing it", user, lastRule) - return err - } - - log.Debugf("User %v account closed. Last rule broken = %v. Detail: %s", user, lastRule, extraDetails) - - return nil } -// Suspended indicates the the user exists (is presently connected) and is -// suspended. This does not access the persistent storage. -func (auth *AuthManager) Suspended(user account.AccountID) (found, suspended bool) { +// AcctStatus indicates if the user is presently connected and their tier. +func (auth *AuthManager) AcctStatus(user account.AccountID) (connected bool, tier int64) { client := auth.user(user) if client == nil { - // TODO: consider hitting auth.storage.Account(user) - return false, true // suspended for practical purposes + // Load user info from DB. + tier, _ = auth.ComputeUserTier(user) + return } - return true, client.isSuspended() -} + connected = true -// Unban forgives a user, allowing them to resume trading if their score permits -// it. Use ForgiveMatchFail to forgive specific match negotiation failures. -func (auth *AuthManager) Unban(user account.AccountID) error { - client := auth.user(user) - if client != nil { - // If client is connected, mark the user as not suspend. - client.restore() - } - return auth.storage.RestoreAccount(user) + client.mtx.Lock() + tier = client.tier + client.mtx.Unlock() + + return } // ForgiveMatchFail forgives a user for a specific match failure, potentially -// allowing them to resume trading if their score becomes passing. +// allowing them to resume trading if their score becomes passing. NOTE: This +// may become deprecated with mesh, unless matches may be forgiven in some +// automatic network reconciliation process. func (auth *AuthManager) ForgiveMatchFail(user account.AccountID, mid order.MatchID) (forgiven, unbanned bool, err error) { // Forgive the specific match failure in the DB. forgiven, err = auth.storage.ForgiveMatchFail(mid) @@ -923,20 +1101,27 @@ func (auth *AuthManager) ForgiveMatchFail(user account.AccountID, mid order.Matc return } - // Recompute the user's score. - var score int32 - score, err = auth.loadUserScore(user) - if err != nil { - return + // Reload outcomes from DB. NOTE: This does not use loadUserScore because we + // also need to update the matchOutcomes map if the user is online. + latestMatches, latestPreimageResults, latestFinished, err := auth.loadUserOutcomes(user) + auth.violationMtx.Lock() + _, online := auth.matchOutcomes[user] + if online { + auth.matchOutcomes[user] = latestMatches // other outcomes unchanged } + auth.violationMtx.Unlock() - // Restore the account if score is sub-threshold. - if score < int32(auth.banScore) { - if err = auth.Unban(user); err == nil { - unbanned = true - } + // Recompute the user's score. + score, _, _ := auth.integrateOutcomes(latestMatches, latestPreimageResults, latestFinished) + + // Recompute tier. + tier, _, changed := auth.computeUserTier(user, score) + if changed { + go auth.sendTierChanged(user, tier, "swap failure forgiven") } + unbanned = tier > 0 + return } @@ -956,6 +1141,129 @@ func (auth *AuthManager) conn(conn comms.Link) *clientInfo { return auth.conns[conn.ID()] } +// sendTierChanged sends a tierchanged notification to an account. +func (auth *AuthManager) sendTierChanged(acctID account.AccountID, newTier int64, reason string) { + log.Debugf("Sending tierchanged notification to %v, new tier = %d, reason = %v", + acctID, newTier, reason) + tierChangedNtfn := &msgjson.TierChangedNotification{ + Tier: newTier, + Reason: reason, + } + auth.Sign(tierChangedNtfn) + resp, err := msgjson.NewNotification(msgjson.TierChangeRoute, tierChangedNtfn) + if err != nil { + log.Error("TierChangeRoute encoding error: %v", err) + return + } + if err = auth.Send(acctID, resp); err != nil { + log.Warnf("Error sending tier changed notification to account %v: %v", acctID, err) + // The user will need to 'connect' to see their current tier and bonds. + } +} + +// sendBondExpired sends a bondexpired notification to an account. +func (auth *AuthManager) sendBondExpired(acctID account.AccountID, bond *db.Bond, newTier int64) { + log.Debugf("Sending bondexpired notification to %v for bond %v (%s), new tier = %d", + acctID, coinIDString(bond.AssetID, bond.CoinID), dex.BipIDSymbol(bond.AssetID), newTier) + bondExpNtfn := &msgjson.BondExpiredNotification{ + AssetID: bond.AssetID, + BondCoinID: bond.CoinID, + AccountID: acctID[:], + Tier: newTier, + } + auth.Sign(bondExpNtfn) + resp, err := msgjson.NewNotification(msgjson.BondExpiredRoute, bondExpNtfn) + if err != nil { + log.Error("BondExpiredRoute encoding error: %v", err) + return + } + if err = auth.Send(acctID, resp); err != nil { + log.Warnf("Error sending bond expired notification to account %v: %v", acctID, err) + // The user will need to 'connect' to see their current tier and bonds. + } +} + +// checkBonds checks all connected users' bonds expiry and recomputes user tier +// on change. This should be run on a ticker. +func (auth *AuthManager) checkBonds() { + lockTimeThresh := time.Now().Add(auth.bondExpiry).Unix() + + checkClientBonds := func(client *clientInfo) ([]*db.Bond, int64, int64) { + client.mtx.Lock() + defer client.mtx.Unlock() + pruned, bondTier := client.pruneBonds(lockTimeThresh) + if len(pruned) == 0 { + return nil, bondTier, client.tier // no tier change + } + + auth.violationMtx.Lock() + score := auth.userScore(client.acct.ID) + auth.violationMtx.Unlock() + + client.tier = auth.tier(bondTier, score, client.legacyFeePaid) + + return pruned, bondTier, client.tier + } + + auth.connMtx.RLock() + defer auth.connMtx.RUnlock() + + type checkRes struct { + tier int64 + bonds []*db.Bond + } + expiredBonds := make(map[account.AccountID]checkRes) + for acct, client := range auth.users { + pruned, bondTier, newTier := checkClientBonds(client) + if len(pruned) > 0 { + log.Infof("Pruned %d expired bonds for user %v, new bond tier = %d, new trading tier = %d", + len(pruned), acct, bondTier, client.tier) + expiredBonds[acct] = checkRes{newTier, pruned} + } + } + + if len(expiredBonds) == 0 { + return // skip the goroutine alloc + } + + auth.wg.Add(1) + go func() { // godspeed + defer auth.wg.Done() + for acct, prunes := range expiredBonds { + for _, bond := range prunes.bonds { + if err := auth.storage.DeleteBond(bond.AssetID, bond.CoinID); err != nil { + log.Errorf("Failed to delete expired bond %v (%s) for user %v: %v", + coinIDString(bond.AssetID, bond.CoinID), dex.BipIDSymbol(bond.AssetID), acct, err) + } + auth.sendBondExpired(acct, bond, prunes.tier) + } + } + }() +} + +// addBond registers a new active bond for an authenticated user. This only +// updates their clientInfo.{bonds,tier} fields. It does not touch the DB. If +// the user is not authenticated, it returns -1, -1. +func (auth *AuthManager) addBond(user account.AccountID, bond *db.Bond) (bondTier, tier int64) { + client := auth.user(user) + if client == nil { + return -1, -1 // offline + } + + auth.violationMtx.Lock() + score := auth.userScore(user) + auth.violationMtx.Unlock() + + client.mtx.Lock() + defer client.mtx.Unlock() + + bondTier = client.addBond(bond) + tier = auth.tier(bondTier, score, client.legacyFeePaid) + client.tier = tier + + return +} + // addClient adds the client to the users and conns maps, and stops any unbook // timers started when they last disconnected. func (auth *AuthManager) addClient(client *clientInfo) { @@ -1020,17 +1328,18 @@ func (auth *AuthManager) removeClient(client *clientInfo) { auth.violationMtx.Lock() delete(auth.matchOutcomes, user) delete(auth.preimgOutcomes, user) + delete(auth.orderOutcomes, user) auth.violationMtx.Unlock() } // loadUserOutcomes returns user's latest match and preimage outcomes from order // and swap data retrieved from the DB. -func (auth *AuthManager) loadUserOutcomes(user account.AccountID) (*latestMatchOutcomes, *latestPreimageOutcomes, error) { +func (auth *AuthManager) loadUserOutcomes(user account.AccountID) (*latestMatchOutcomes, *latestPreimageOutcomes, *latestOrders, error) { // Load the N most recent matches resulting in success or an at-fault match // revocation for the user. matchOutcomes, err := auth.storage.CompletedAndAtFaultMatchStats(user, scoringMatchLimit) if err != nil { - return nil, nil, fmt.Errorf("CompletedAndAtFaultMatchStats: %w", err) + return nil, nil, nil, fmt.Errorf("CompletedAndAtFaultMatchStats: %w", err) } matchStatusToViol := func(status order.MatchStatus) Violation { @@ -1053,7 +1362,7 @@ func (auth *AuthManager) loadUserOutcomes(user account.AccountID) (*latestMatchO // Load the count of preimage misses in the N most recently placed orders. piOutcomes, err := auth.storage.PreimageStats(user, scoringOrderLimit) if err != nil { - return nil, nil, fmt.Errorf("PreimageStats: %w", err) + return nil, nil, nil, fmt.Errorf("PreimageStats: %w", err) } latestMatches := newLatestMatchOutcomes(scoringMatchLimit) @@ -1083,35 +1392,26 @@ func (auth *AuthManager) loadUserOutcomes(user account.AccountID) (*latestMatchO }) } - return latestMatches, latestPreimageResults, nil + // Retrieve the user's N latest finished (completed or canceled orders) + // and store them in a latestOrders. + orderOutcomes, err := auth.loadRecentFinishedOrders(user, cancelThreshWindow) + if err != nil { + log.Errorf("Unable to retrieve user's executed cancels and completed orders: %v", err) + return nil, nil, nil, err + } + + return latestMatches, latestPreimageResults, orderOutcomes, nil } // loadUserScore computes the user's current score from order and swap data -// retrieved from the DB. The creates entries in the matchOutcomes and -// preimgOutcomes maps for the user. +// retrieved from the DB. Use this instead of userScore if the user is offline. func (auth *AuthManager) loadUserScore(user account.AccountID) (int32, error) { - // Load the N most recent matches resulting in success or an at-fault match - // revocation for the user. - latestMatches, latestPreimageResults, err := auth.loadUserOutcomes(user) + latestMatches, latestPreimageResults, latestFinished, err := auth.loadUserOutcomes(user) if err != nil { return 0, err } - score, successCount, piMissCount := integrateOutcomes(latestMatches, latestPreimageResults) - - // Make outcome entries for the user. - auth.violationMtx.Lock() - auth.matchOutcomes[user] = latestMatches - auth.preimgOutcomes[user] = latestPreimageResults - auth.violationMtx.Unlock() - - successScore := successCount * successScore // negative - piMissScore := piMissCount * preimageMissScore - // score = violationScore + piMissScore + successScore - violationScore := score - piMissScore - successScore - log.Debugf("User %v score = %d: %d (violations) + %d (%d preimage misses) - %d (%d successes)", - user, score, violationScore, piMissScore, piMissCount, -successScore, successCount) - + score, _, _ := auth.integrateOutcomes(latestMatches, latestPreimageResults, latestFinished) return score, nil } @@ -1134,24 +1434,25 @@ func (auth *AuthManager) handleConnect(conn comms.Link, msg *msgjson.Message) *m } var user account.AccountID copy(user[:], connect.AccountID[:]) - acctInfo, paid, open := auth.storage.Account(user) + lockTimeThresh := time.Now().Add(auth.bondExpiry) + acctInfo, bonds, legacy, legacyPaid := auth.storage.Account(user, lockTimeThresh) if acctInfo == nil { return &msgjson.Error{ Code: msgjson.AccountNotFoundError, Message: "no account found for account ID: " + connect.AccountID.String(), } } - if !paid { - // TODO: Send pending responses (e.g. a 'register` response that - // contains the fee address and amount for the user). Use - // rmUserConnectMsgs and rmUserConnectReqs to get them by account ID. + if legacy && !legacyPaid { + // They began the legacy 'register' sequence, but did not 'notifyfee' + // yet. (V0PURGE) return &msgjson.Error{ Code: msgjson.UnpaidAccountError, Message: "unpaid account", } } - // Note: suspended accounts may connect to complete swaps, etc. but not - // place new orders. + + // Tier 0 accounts may connect to complete swaps, etc. but not place new + // orders. // Authorize the account. sigMsg := connect.Serialize() @@ -1172,39 +1473,8 @@ func (auth *AuthManager) handleConnect(conn comms.Link, msg *msgjson.Message) *m oldClient.mtx.Unlock() } - // Retrieve the user's N latest finished (completed or canceled) orders - // and store them in a latestOrders. - latestFinished, err := auth.loadRecentFinishedOrders(acctInfo.ID, cancelThreshWindow) - if err != nil { - log.Errorf("Unable to retrieve user's executed cancels and completed orders: %v", err) - return &msgjson.Error{ - Code: msgjson.RPCInternalError, - Message: "DB error", - } - } - client := &clientInfo{ - acct: acctInfo, - conn: conn, - respHandlers: respHandlers, - recentOrders: latestFinished, - suspended: !open, - } - - // Compute the user's cancellation rate. - cancels, completions, rate, penalize := auth.checkCancelRate(client) - if penalize && open && !auth.freeCancels { - // Account should already be closed, but perhaps the server crashed - // or the account was not penalized before shutdown. - client.suspended = true - // The account might now be closed if the cancellation rate was - // exceeded while the server was running in anarchy mode. - auth.storage.CloseAccount(acctInfo.ID, account.CancellationRate) - log.Debugf("Suspended account %v (cancellation rate = %.2f%%, %d cancels : %d successes) connected.", - acctInfo.ID, 100*rate, cancels, completions) - } - - // Compute the user's ban score. - score, err := auth.loadUserScore(user) + // Compute the user's ban score, loading the preimage/order/match outcomes. + latestMatches, latestPreimageResults, latestFinished, err := auth.loadUserOutcomes(user) if err != nil { log.Errorf("Failed to compute user %v score: %v", user, err) return &msgjson.Error{ @@ -1212,22 +1482,36 @@ func (auth *AuthManager) handleConnect(conn comms.Link, msg *msgjson.Message) *m Message: "DB error", } } - if score >= int32(auth.banScore) && open && !auth.anarchy { - // Would already be penalized unless server changed the banScore or - // was previously running in anarchy mode. - client.suspended = true - auth.storage.CloseAccount(user, account.FailureToAct) - log.Debugf("Suspended account %v (score = %d) connected.", acctInfo.ID, score) - } else if score < int32(auth.banScore) && !open { - // banScore is a configurable threshold that may have changed. This also - // assists account recovery in the event of an online accounting bug. - if err = auth.Unban(user); err == nil { - log.Warnf("Restoring suspended account %v (score = %d).", acctInfo.ID, score) - client.suspended = false - } else { - log.Errorf("Failed to restore suspended account %v (score = %d): %v.", - acctInfo.ID, score, err) - } + score, successCount, piMissCount := auth.integrateOutcomes(latestMatches, latestPreimageResults, latestFinished) + + successScore := successCount * successScore // negative + piMissScore := piMissCount * preimageMissScore + // score = violationScore + piMissScore + successScore + violationScore := score - piMissScore - successScore // work backwards as per above comment + log.Debugf("User %v score = %d: %d (violations) + %d (%d preimage misses) - %d (%d successes)", + user, score, violationScore, piMissScore, piMissCount, -successScore, successCount) + + // Make outcome entries for the user. + auth.violationMtx.Lock() + auth.matchOutcomes[user] = latestMatches + auth.preimgOutcomes[user] = latestPreimageResults + auth.orderOutcomes[user] = latestFinished + auth.violationMtx.Unlock() + + var bondTier int64 + for _, bond := range bonds { + bondTier += int64(bond.Strength) + } + + tier := auth.tier(bondTier, score, legacyPaid) + + client := &clientInfo{ + acct: acctInfo, + conn: conn, + respHandlers: respHandlers, + tier: tier, + bonds: bonds, + legacyFeePaid: legacyPaid, } // Get the list of active orders for this user. @@ -1305,14 +1589,38 @@ func (auth *AuthManager) handleConnect(conn comms.Link, msg *msgjson.Message) *m conn.Authorized() + // Prepare bond info for response. + msgBonds := make([]*msgjson.Bond, 0, len(bonds)) + for _, bond := range bonds { + // Double check the DB backend's thresholding. + lockTime := time.Unix(bond.LockTime, 0) + if lockTime.Before(lockTimeThresh) { + log.Warnf("Loaded expired bond from DB (%v), lockTime %v is before %v", + coinIDString(bond.AssetID, bond.CoinID), lockTime, lockTimeThresh) + continue // will be expired on next prune + } + expireTime := lockTime.Add(-auth.bondExpiry) + msgBonds = append(msgBonds, &msgjson.Bond{ + Version: bond.Version, + Amount: uint64(bond.Amount), + Expiry: uint64(expireTime.Unix()), + CoinID: bond.CoinID, + AssetID: bond.AssetID, + }) + } + // Sign and send the connect response. + suspended := tier < 1 // for legacy clients sig := auth.SignMsg(sigMsg) resp := &msgjson.ConnectResult{ Sig: sig, ActiveOrderStatuses: msgOrderStatuses, ActiveMatches: msgMatches, Score: score, - Suspended: &client.suspended, + Tier: &tier, + ActiveBonds: msgBonds, + Suspended: &suspended, // V0PURGE + LegacyFeePaid: &legacyPaid, // courtesy for account discovery } respMsg, err := msgjson.NewResponse(msg.ID, resp, nil) if err != nil { @@ -1329,9 +1637,10 @@ func (auth *AuthManager) handleConnect(conn comms.Link, msg *msgjson.Message) *m return nil } - log.Infof("Authenticated account %v from %v with %d active orders, %d active matches (score = %d, suspended = %v)", - user, conn.Addr(), len(msgOrderStatuses), len(msgMatches), score, client.suspended) + log.Infof("Authenticated account %v from %v with %d active orders, %d active matches, tier = %v", + user, conn.Addr(), len(msgOrderStatuses), len(msgMatches), client.tier) auth.addClient(client) + return nil } diff --git a/server/auth/auth_test.go b/server/auth/auth_test.go index 2dd9ee3711..7b27f30c6c 100644 --- a/server/auth/auth_test.go +++ b/server/auth/auth_test.go @@ -53,7 +53,6 @@ type TStorage struct { acctInfoErr error acct *account.Account matches []*db.MatchData - closedID account.AccountID matchStatuses []*db.MatchStatus userPreimageResults []*db.PreimageResult userMatchOutcomes []*db.MatchOutcome @@ -63,24 +62,21 @@ type TStorage struct { regAsset uint32 regErr error payErr error - unpaid bool - closed bool + bonds []*db.Bond + legacy bool + legacyPaid bool ratio ratioData } -func (s *TStorage) CloseAccount(id account.AccountID, _ account.Rule) error { - s.closedID = id - return nil -} -func (s *TStorage) RestoreAccount(_ account.AccountID) error { - return nil -} func (s *TStorage) AccountInfo(account.AccountID) (*db.Account, error) { return s.acctInfo, s.acctInfoErr } -func (s *TStorage) Account(account.AccountID) (*account.Account, bool, bool) { - return s.acct, !s.unpaid, !s.closed +func (s *TStorage) Account(acct account.AccountID, lockTimeThresh time.Time) (*account.Account, []*db.Bond, bool, bool) { + return s.acct, s.bonds, s.legacy, s.legacyPaid } +func (s *TStorage) CreateAccountWithBond(acct *account.Account, bond *db.Bond) error { return nil } +func (s *TStorage) AddBond(acct account.AccountID, bond *db.Bond) error { return nil } +func (s *TStorage) DeleteBond(assetID uint32, coinID []byte) error { return nil } func (s *TStorage) CompletedAndAtFaultMatchStats(aid account.AccountID, lastN int) ([]*db.MatchOutcome, error) { return s.userMatchOutcomes, nil } @@ -137,10 +133,12 @@ func (s *TStorage) ExecutedCancelsForUser(aid account.AccountID, _ int) (cancels // TSigner satisfies the Signer interface type TSigner struct { - sig *ecdsa.Signature + sig *ecdsa.Signature + //privKey *secp256k1.PrivateKey pubkey *secp256k1.PublicKey } +// Maybe actually change this to an ecdsa.Sign with a private key instead? func (s *TSigner) Sign(hash []byte) *ecdsa.Signature { return s.sig } func (s *TSigner) PubKey() *secp256k1.PublicKey { return s.pubkey } @@ -247,14 +245,15 @@ type tUser struct { privKey *secp256k1.PrivateKey } +// makes a new user with its own account ID, tRPCClient func tNewUser(t *testing.T) *tUser { + t.Helper() conn := tNewRPCClient() - // register the RPCClient with a 'connect' Message - acctID := newAccountID() privKey, err := secp256k1.GeneratePrivateKey() if err != nil { t.Fatalf("error generating private key: %v", err) } + acctID := account.NewID(privKey.PubKey().SerializeCompressed()) return &tUser{ conn: conn, acctID: acctID, @@ -379,6 +378,9 @@ var ( tCheckFeeVal uint64 = 500_000_000 tCheckFeeConfs int64 = 5 tCheckFeeErr error + + tParseBondTxAcct account.AccountID + tParseBondTxErr error ) func tCheckFee(assetID uint32, coin []byte) (addr string, val uint64, confs int64, err error) { @@ -389,6 +391,11 @@ func tCheckFee(assetID uint32, coin []byte) (addr string, val uint64, confs int6 return tCheckFeeAddr, tCheckFeeVal, tCheckFeeConfs, tCheckFeeErr } +func tParseBondTx(assetID uint32, ver uint16, rawTx []byte) (bondCoinID []byte, amt int64, + lockTime int64, acct account.AccountID, err error) { + return nil, 0, time.Now().Add(time.Minute).Unix(), tParseBondTxAcct, tParseBondTxErr +} + func tFeeAddress(assetID uint32) string { if assetID != 42 { return "" @@ -408,7 +415,10 @@ var tDexPubKeyBytes = []byte{ } func resetStorage() { - *rig.storage = TStorage{} + *rig.storage = TStorage{ + legacy: true, + legacyPaid: true, + } } func TestMain(m *testing.M) { @@ -416,13 +426,27 @@ func TestMain(m *testing.M) { UseLogger(dex.StdOutLogger("AUTH_TEST", dex.LevelTrace)) ctx, shutdown := context.WithCancel(context.Background()) defer shutdown() - storage := &TStorage{} + storage := &TStorage{ + legacy: true, + legacyPaid: true, + } + // secp256k1.PrivKeyFromBytes dexKey, _ := secp256k1.ParsePubKey(tDexPubKeyBytes) signer := &TSigner{pubkey: dexKey} authMgr := NewAuthManager(&Config{ Storage: storage, Signer: signer, - FeeAddress: tFeeAddress, + BondExpiry: 86400, + BondAssets: map[string]*msgjson.BondAsset{ + "dcr": { + Version: 0, + ID: 42, + Confs: uint32(tCheckFeeConfs), + Amt: tRegFee * 10, + }, + }, + BondTxParser: tParseBondTx, + FeeAddress: tFeeAddress, FeeAssets: map[string]*msgjson.FeeAsset{ "dcr": { ID: 42, @@ -721,6 +745,14 @@ func TestConnect(t *testing.T) { }) // 1:1 = 50% defer rig.storage.setRatioData(&ratioData{}) // clean slate + // TODO: update tests now that there is are no close/ban and unban + // operations, instead an integral tier. + + // TODO: update tests now that cancel ratio is part of the score equation + // rather than a hard close operation. + + /* cancel ratio stuff + // Close account on connect with failing cancel ratio, and no grace period. rig.mgr.cancelThresh = 0.2 // thresh below actual ratio, and no grace period with total/(1+total) = 2/3 = 0.66... > 0.2 tryConnectUser(t, user, false) @@ -799,6 +831,8 @@ func TestConnect(t *testing.T) { t.Fatalf("Expected account %v to NOT be closed on connect, but it was.", user) } + */ + // Connect with a violation score above ban score. wantScore := setViolations() defer clearViolations() @@ -819,10 +853,6 @@ func TestConnect(t *testing.T) { // No error, but Penalize account that was not previously closed. tryConnectUser(t, user, false) - if rig.storage.closedID != user.acctID { - t.Fatalf("penalty not stored") - } - rig.storage.closedID = account.AccountID{} makerSwapCastIdx := 3 rig.storage.userMatchOutcomes = append(rig.storage.userMatchOutcomes[:makerSwapCastIdx], rig.storage.userMatchOutcomes[makerSwapCastIdx+1:]...) @@ -840,9 +870,6 @@ func TestConnect(t *testing.T) { // Connect the user. respMsg := connectUser(t, user) - if rig.storage.closedID == user.acctID { - t.Fatalf("user unexpectedly penalized") - } cResp := extractConnectResult(t, respMsg) if len(cResp.ActiveOrderStatuses) != 1 { t.Fatalf("no active orders") @@ -930,7 +957,10 @@ func TestConnect(t *testing.T) { connectUser(t, reuser) a10 := &tPayload{A: 10} msg, _ = msgjson.NewRequest(comms.NextID(), "request", a10) - rig.mgr.RequestWithTimeout(reuser.acctID, msg, func(comms.Link, *msgjson.Message) {}, time.Minute, func() {}) + err = rig.mgr.RequestWithTimeout(reuser.acctID, msg, func(comms.Link, *msgjson.Message) {}, time.Minute, func() {}) + if err != nil { + t.Fatalf("request failed: %v", err) + } // The a10 message should be in the new connection if user.conn.getReq() != nil { t.Fatalf("old connection received a request after reconnection") @@ -991,10 +1021,10 @@ func TestAccountErrors(t *testing.T) { t.Fatal("wrong match time: ", match.ServerTime, " != ", uint64(matchTime.UnixMilli())) } - // unpaid account. - rig.storage.unpaid = true + // unpaid account. what should db.Account's legacyPaid bool affect? + rig.storage.legacyPaid = false rpcErr := rig.mgr.handleConnect(user.conn, connect) - rig.storage.unpaid = false + rig.storage.legacyPaid = true if rpcErr == nil { t.Fatalf("no error for unpaid account") } @@ -1011,9 +1041,7 @@ func TestAccountErrors(t *testing.T) { rig.mgr.removeClient(rig.mgr.user(user.acctID)) // disconnect first, NOTE that link.Disconnect is async user.conn = tNewRPCClient() // disconnect necessitates new conn ID - rig.storage.closed = true rpcErr = rig.mgr.handleConnect(user.conn, connect) - rig.storage.closed = false if rpcErr != nil { t.Fatalf("should be no error for closed account") } @@ -1021,8 +1049,8 @@ func TestAccountErrors(t *testing.T) { if client == nil { t.Fatalf("client not found") } - if !client.isSuspended() { - t.Errorf("client should have been in suspended state") + if client.tier > 0 { + t.Errorf("client should have been tier 0") } // Raise the ban score threshold to ensure automatic reinstatement. @@ -1032,9 +1060,7 @@ func TestAccountErrors(t *testing.T) { rig.mgr.removeClient(rig.mgr.user(user.acctID)) // disconnect first, NOTE that link.Disconnect is async user.conn = tNewRPCClient() // disconnect necessitates new conn ID - rig.storage.closed = true rpcErr = rig.mgr.handleConnect(user.conn, connect) - rig.storage.closed = false if rpcErr != nil { t.Fatalf("should be no error for closed account") } @@ -1042,8 +1068,8 @@ func TestAccountErrors(t *testing.T) { if client == nil { t.Fatalf("client not found") } - if client.isSuspended() { - t.Errorf("client should have unbaned automatically") + if client.tier < 1 { + t.Errorf("client should have unbanned automatically") } } @@ -1189,34 +1215,6 @@ func TestSend(t *testing.T) { } } -func TestPenalize(t *testing.T) { - user := tNewUser(t) - rig.signer.sig = user.randomSignature() - connectUser(t, user) - foreigner := tNewUser(t) - - // Cannot set account as suspended in the clients map if they are not - // connected, but should still suspend in DB. - rig.mgr.Penalize(foreigner.acctID, 0, "details") - var zeroAcct account.AccountID - // if rig.storage.closedID != zeroAcct { - // t.Fatalf("foreigner penalty stored") - // } - rig.mgr.Penalize(user.acctID, 0, "details") - if rig.storage.closedID != user.acctID { - t.Fatalf("penalty not stored") - } - rig.storage.closedID = zeroAcct - if user.conn.banished { - t.Fatalf("penalized user should not be banished") - } - - // The user should remain in the map to finish their work. - if rig.mgr.user(user.acctID) == nil { - t.Fatalf("penalized user should not be removed from map") - } -} - func TestConnectErrors(t *testing.T) { user := tNewUser(t) rig.storage.acct = nil @@ -1253,12 +1251,12 @@ func TestConnectErrors(t *testing.T) { ensureErr(rpcErr, "account unknown to storage", msgjson.AccountNotFoundError) rig.storage.acct = &account.Account{ID: user.acctID, PubKey: user.privKey.PubKey()} - // User unpaid + // *legacy* user unpaid encodeMsg() - rig.storage.unpaid = true + rig.storage.legacyPaid = false rpcErr = rig.mgr.handleConnect(user.conn, msg) ensureErr(rpcErr, "account unpaid", msgjson.UnpaidAccountError) - rig.storage.unpaid = false + rig.storage.legacyPaid = true // bad signature connect.SetSig([]byte{0x09, 0x08}) @@ -1365,8 +1363,11 @@ func TestHandleResponse(t *testing.T) { client.mtx.Unlock() } +// TODO: TestHandlePostBond + func TestHandleRegister(t *testing.T) { user := tNewUser(t) + resetStorage() dummyError := fmt.Errorf("test error") rig.storage.acctInfoErr = db.ArchiveError{Code: db.ErrAccountUnknown} @@ -1399,6 +1400,10 @@ func TestHandleRegister(t *testing.T) { ensureErr := makeEnsureErr(t) msg := newMsg(newReg()) + msg.Payload = []byte(`null`) + ensureErr(do(msg), "null payload", msgjson.RPCParseError) + + msg = newMsg(newReg()) msg.Payload = []byte(`?`) ensureErr(do(msg), "bad payload", msgjson.RPCParseError) @@ -1461,9 +1466,8 @@ func TestHandleNotifyFee(t *testing.T) { user := tNewUser(t) userAcct := &account.Account{ID: user.acctID, PubKey: user.privKey.PubKey()} userDBAcct := &db.Account{ - AccountID: user.acctID, - BrokenRule: account.NoRule, - Pubkey: user.privKey.PubKey().SerializeCompressed(), + AccountID: user.acctID, + Pubkey: user.privKey.PubKey().SerializeCompressed(), } rig.storage.acct = userAcct rig.storage.acctInfo = userDBAcct @@ -1478,7 +1482,7 @@ func TestHandleNotifyFee(t *testing.T) { rig.storage.regAddr = tCheckFeeAddr defer func() { - rig.storage.unpaid = false + rig.storage.legacyPaid = true }() defer resetStorage() @@ -1557,7 +1561,7 @@ func TestHandleNotifyFee(t *testing.T) { userDBAcct.FeeCoin = nil // Signature error - rig.storage.unpaid = true + rig.storage.legacyPaid = false notify = newNotify() notify.Sig = []byte{0x01, 0x02} ensureErr(do(newMsg(notify)), "bad signature", msgjson.SignatureError) @@ -1605,13 +1609,13 @@ func TestAuthManager_RecordCancel_RecordCompletedOrder(t *testing.T) { return } + orderOutcomes := rig.mgr.orderOutcomes[user.acctID] + oid := newOrderID() tCompleted := unixMsNow() rig.mgr.RecordCompletedOrder(user.acctID, oid, tCompleted) - client.mtx.Lock() - total, cancels := client.recentOrders.counts() - client.mtx.Unlock() + total, cancels := orderOutcomes.counts() if total != 1 { t.Errorf("got %d total orders, expected %d", total, 1) } @@ -1634,9 +1638,7 @@ func TestAuthManager_RecordCancel_RecordCompletedOrder(t *testing.T) { } } - client.mtx.Lock() - ord := client.recentOrders.orders[0] - client.mtx.Unlock() + ord := orderOutcomes.orders[0] checkOrd(ord, oid, false, tCompleted.UnixMilli()) // another @@ -1644,9 +1646,7 @@ func TestAuthManager_RecordCancel_RecordCompletedOrder(t *testing.T) { tCompleted = tCompleted.Add(time.Millisecond) // newer rig.mgr.RecordCompletedOrder(user.acctID, oid, tCompleted) - client.mtx.Lock() - total, cancels = client.recentOrders.counts() - client.mtx.Unlock() + total, cancels = orderOutcomes.counts() if total != 2 { t.Errorf("got %d total orders, expected %d", total, 2) } @@ -1654,9 +1654,7 @@ func TestAuthManager_RecordCancel_RecordCompletedOrder(t *testing.T) { t.Errorf("got %d cancels, expected %d", cancels, 0) } - client.mtx.Lock() - ord = client.recentOrders.orders[1] - client.mtx.Unlock() + ord = orderOutcomes.orders[1] checkOrd(ord, oid, false, tCompleted.UnixMilli()) // now a cancel @@ -1664,9 +1662,7 @@ func TestAuthManager_RecordCancel_RecordCompletedOrder(t *testing.T) { tCompleted = tCompleted.Add(time.Millisecond) // newer rig.mgr.RecordCancel(user.acctID, coid, oid, 1, tCompleted) - client.mtx.Lock() - total, cancels = client.recentOrders.counts() - client.mtx.Unlock() + total, cancels = orderOutcomes.counts() if total != 3 { t.Errorf("got %d total orders, expected %d", total, 3) } @@ -1674,9 +1670,7 @@ func TestAuthManager_RecordCancel_RecordCompletedOrder(t *testing.T) { t.Errorf("got %d cancels, expected %d", cancels, 1) } - client.mtx.Lock() - ord = client.recentOrders.orders[2] - client.mtx.Unlock() + ord = orderOutcomes.orders[2] checkOrd(ord, coid, true, tCompleted.UnixMilli()) } diff --git a/server/auth/registrar.go b/server/auth/registrar.go index 49f2894723..e60953e978 100644 --- a/server/auth/registrar.go +++ b/server/auth/registrar.go @@ -4,11 +4,14 @@ package auth import ( + "bytes" + "context" "errors" "fmt" "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/wait" "decred.org/dcrdex/server/account" @@ -22,10 +25,385 @@ var ( recheckInterval = time.Second * 5 // txWaitExpiration is the longest the AuthManager will wait for a coin // waiter. This could be thought of as the maximum allowable backend latency. - txWaitExpiration = time.Minute + txWaitExpiration = 2 * time.Minute ) +// 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...)) +} + +func (auth *AuthManager) registerBondWaiter(key string) bool { + auth.bondWaiterMtx.Lock() + defer auth.bondWaiterMtx.Unlock() + if _, found := auth.bondWaiterIdx[key]; found { + return false + } + auth.bondWaiterIdx[key] = struct{}{} + return true +} + +func (auth *AuthManager) removeBondWaiter(key string) { + auth.bondWaiterMtx.Lock() + delete(auth.bondWaiterIdx, key) + auth.bondWaiterMtx.Unlock() +} + +// handlePreValidateBond handles the 'prevalidatebond' request. +// +// The request payload includes the user's account public key and the serialized +// bond post transaction itself (not just the txid). +// +// The parseBondTx function is used to validate the transaction, and extract +// bond details (amount and lock time) and the account ID to which it commits. +// This also checks that the account commitment corresponds to the user's public +// key provided in the payload. If these requirements are satisfied, the client +// will receive a PreValidateBondResult in the response. The user should then +// proceed to broadcast the bond and use the 'postbond' route once it reaches +// the required number of confirmations. +func (auth *AuthManager) handlePreValidateBond(conn comms.Link, msg *msgjson.Message) *msgjson.Error { + preBond := new(msgjson.PreValidateBond) + err := msg.Unmarshal(&preBond) + if err != nil || preBond == nil { + return msgjson.NewError(msgjson.BondError, "error parsing prevalidatebond request") + } + + assetID := preBond.AssetID + bondAsset, ok := auth.bondAssets[assetID] + if !ok { + return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") + } + if assetID != 42 { // Temporary! need to update tier computations for different bond increment/amounts! + return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") + } + + // Create an account.Account from the provided pubkey. + acct, err := account.NewAccountFromPubKey(preBond.AcctPubKey) + if err != nil { + return msgjson.NewError(msgjson.BondError, "error parsing account pubkey: "+err.Error()) + } + acctID := acct.ID + + // Authenticate the message for the supposed account. + sigMsg := preBond.Serialize() + err = checkSigS256(sigMsg, preBond.SigBytes(), acct.PubKey) + if err != nil { + return &msgjson.Error{ + Code: msgjson.SignatureError, + Message: "signature error: " + err.Error(), + } + } + + // A bond's lockTime must be after bondExpiry from now. + lockTimeThresh := time.Now().Add(auth.bondExpiry) + + // Decode raw tx, check fee output (0) and account commitment output (1). + bondCoinID, amt, lockTime, commitAcct, err := + auth.parseBondTx(assetID, preBond.Version, preBond.RawTx /*, postBond.Data*/) + if err != nil { + return msgjson.NewError(msgjson.BondError, "invalid bond transaction: %v", err) + } + if amt < int64(bondAsset.Amt) { + return msgjson.NewError(msgjson.BondError, "insufficient bond amount %d, needed %d", amt, bondAsset.Amt) + } + if lockTime < lockTimeThresh.Unix() { + return msgjson.NewError(msgjson.BondError, "insufficient lock time %d, needed at least %d", lockTime, lockTimeThresh.Unix()) + } + + // Must be equal to account ID computed from pubkey in the PayFee message. + if commitAcct != acctID { + return msgjson.NewError(msgjson.BondError, "invalid bond transaction - account commitment does not match pubkey") + } + + bondStr := coinIDString(assetID, bondCoinID) + bondAssetSym := dex.BipIDSymbol(assetID) + log.Debugf("Validated prospective bond txn output %s (%s) paying %d for user %v", + bondStr, bondAssetSym, amt, acctID) + + expireTime := time.Unix(lockTime, 0).Add(-auth.bondExpiry) + preBondRes := &msgjson.PreValidateBondResult{ + AccountID: acctID[:], + AssetID: assetID, + Amount: uint64(amt), + Expiry: uint64(expireTime.Unix()), + BondID: bondCoinID, + } + auth.Sign(preBondRes) + + resp, err := msgjson.NewResponse(msg.ID, preBondRes, nil) + if err != nil { // shouldn't be possible + return msgjson.NewError(msgjson.RPCInternalError, "internal encoding error") + } + err = conn.Send(resp) + if err != nil { + log.Warnf("Error sending prevalidatebond result to user %v: %v", acctID, err) + if err = auth.Send(acctID, resp); err != nil { + log.Warnf("Error sending prevalidatebond result to account %v: %v", acctID, err) + } + } + return nil +} + +// handlePostBond handles the 'postbond' request. +// +// The checkBond function is used to locate the bond transaction on the network, +// and verify the amount, lockTime, and account to which it commits. +// +// A 'postbond' request should not be made until the bond transaction has been +// broadcasted and reaches the required number of confirmations. +func (auth *AuthManager) handlePostBond(conn comms.Link, msg *msgjson.Message) *msgjson.Error { + postBond := new(msgjson.PostBond) + err := msg.Unmarshal(&postBond) + if err != nil || postBond == nil { + return msgjson.NewError(msgjson.BondError, "error parsing postbond request") + } + + // TODO: allow different assets for bond, switching parse functions, etc. + assetID := postBond.AssetID + bondAsset, ok := auth.bondAssets[assetID] + if !ok { + return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") + } + if assetID != 42 { // Temporary! need to update tier computations for different bond increment/amounts! + return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") + } + + // Create an account.Account from the provided pubkey. + acct, err := account.NewAccountFromPubKey(postBond.AcctPubKey) + if err != nil { + return msgjson.NewError(msgjson.BondError, "error parsing account pubkey: "+err.Error()) + } + acctID := acct.ID + + // Authenticate the message for the supposed account. + sigMsg := postBond.Serialize() + err = checkSigS256(sigMsg, postBond.SigBytes(), acct.PubKey) + if err != nil { + return &msgjson.Error{ + Code: msgjson.SignatureError, + Message: "signature error: " + err.Error(), + } + } + + // A bond's lockTime must be after bondExpiry from now. + lockTimeThresh := time.Now().Add(auth.bondExpiry) + + bondVer, bondCoinID := postBond.Version, postBond.CoinID + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + amt, lockTime, confs, commitAcct, err := auth.checkBond(ctx, assetID, bondVer, bondCoinID) + if err != nil { + return msgjson.NewError(msgjson.BondError, "invalid bond transaction: %v", err) + } + if amt < int64(bondAsset.Amt) { + return msgjson.NewError(msgjson.BondError, "insufficient bond amount %d, needed %d", amt, bondAsset.Amt) + } + if lockTime < lockTimeThresh.Unix() { + return msgjson.NewError(msgjson.BondError, "insufficient lock time %d, needed at least %d", lockTime, lockTimeThresh.Unix()) + } + + // Must be equal to account ID computed from pubkey in the PayFee message. + if commitAcct != acctID { + return msgjson.NewError(msgjson.BondError, "invalid bond transaction - account commitment does not match pubkey") + } + + // All good. The client gets a PostBondResult (no error) unless the confirms + // check has an unexpected error or times out. + expireTime := time.Unix(lockTime, 0).Add(-auth.bondExpiry) + postBondRes := &msgjson.PostBondResult{ + AccountID: acctID[:], + AssetID: assetID, + Amount: uint64(amt), + Expiry: uint64(expireTime.Unix()), + BondID: bondCoinID, + } + auth.Sign(postBondRes) + + sendResp := func() *msgjson.Error { + resp, err := msgjson.NewResponse(msg.ID, postBondRes, nil) + if err != nil { // shouldn't be possible + return msgjson.NewError(msgjson.RPCInternalError, "internal encoding error") + } + err = conn.Send(resp) + if err != nil { + log.Warnf("Error sending postbond result to user %v: %v", acctID, err) + if err = auth.Send(acctID, resp); err != nil { + log.Warnf("Error sending postbond result to account %v: %v", acctID, err) + // The user will need to 'connect' to reconcile bond status. + } + } + return nil + } + + // See if the account exists, and get known unexpired bonds. Also see if the + // account has previously paid a legacy registration fee. + dbAcct, bonds, _, _ := auth.storage.Account(acctID, lockTimeThresh) + + bondStr := coinIDString(assetID, bondCoinID) + bondAssetSym := dex.BipIDSymbol(assetID) + + // See if we already have this bond in DB. + for _, bond := range bonds { + if bond.AssetID == assetID && bytes.Equal(bond.CoinID, bondCoinID) { + log.Debugf("Found existing bond %s (%s) committing %d for user %v", + bondStr, bondAssetSym, amt, acctID) + _, postBondRes.Tier = auth.AcctStatus(acctID) + return sendResp() + } + } + + dbBond := &db.Bond{ + Version: postBond.Version, + AssetID: assetID, + CoinID: bondCoinID, + Amount: amt, + Strength: uint32(uint64(amt) / bondAsset.Amt), + LockTime: lockTime, + } + + // Either store the bond or start a block waiter to activate the bond and + // respond with a PostBondResult when it is fully-confirmed. + bondIDKey := bondKey(assetID, bondCoinID) + if !auth.registerBondWaiter(bondIDKey) { + // Waiter already running! They'll get a response to their first + // request, or find out on connect if the bond was activated. + return msgjson.NewError(msgjson.BondAlreadyConfirmingError, "bond already submitted") + } + + newAcct := dbAcct == nil + reqConfs := int64(bondAsset.Confs) + + if confs >= reqConfs { + // No need to call checkFee again in a waiter. + log.Debugf("Activating new bond %s (%s) committing %d for user %v", bondStr, bondAssetSym, amt, acctID) + auth.storeBondAndRespond(conn, dbBond, acct, newAcct, msg.ID, postBondRes) + auth.removeBondWaiter(bondIDKey) // after storing it + return nil + } + + // The user should have submitted only when the bond was confirmed, so we + // only expect to wait for asset network latency. + log.Debugf("Found new bond %s (%s) committing %d for user %v. Confirming...", + bondStr, bondAssetSym, amt, acctID) + ctxTry, cancelTry := context.WithTimeout(context.Background(), txWaitExpiration) // prevent checkBond RPC hangs + auth.latencyQ.Wait(&wait.Waiter{ + Expiration: time.Now().Add(txWaitExpiration), + TryFunc: func() wait.TryDirective { + res := auth.waitBondConfs(ctxTry, conn, dbBond, acct, reqConfs, newAcct, msg.ID, postBondRes) + if res == wait.DontTryAgain { + auth.removeBondWaiter(bondIDKey) + cancelTry() + } + return res + }, + ExpireFunc: func() { + auth.removeBondWaiter(bondIDKey) + cancelTry() + // User may retry postbond periodically or on reconnect. + }, + }) + // NOTE: server restart cannot restart these waiters, so user must resubmit + // their postbond after their request times out. + + return nil +} + +func (auth *AuthManager) storeBondAndRespond(conn comms.Link, bond *db.Bond, acct *account.Account, + newAcct bool, reqID uint64, postBondRes *msgjson.PostBondResult) { + acctID := acct.ID + assetID, coinID := bond.AssetID, bond.CoinID + bondStr := coinIDString(assetID, coinID) + bondAssetSym := dex.BipIDSymbol(assetID) + var err error + if newAcct { + log.Infof("Creating new user account %v from %v, posted first bond in %v (%s)", + acctID, conn.Addr(), bondStr, bondAssetSym) + err = auth.storage.CreateAccountWithBond(acct, bond) + } else { + log.Infof("Adding bond for existing user account %v from %v, with bond in %v (%s)", + acctID, conn.Addr(), bondStr, bondAssetSym) + err = auth.storage.AddBond(acct.ID, bond) + } + if err != nil { + log.Errorf("Failure while storing bond for acct %v (new = %v): %v", acct, newAcct, err) + conn.SendError(reqID, &msgjson.Error{ + Code: msgjson.RPCInternalError, + Message: "failed to store bond", + }) + return + } + + // Integrate active bonds and score to report tier. + bondTotal, tier := auth.addBond(acctID, bond) + if bondTotal == -1 { // user not authenticated, use DB + tier, bondTotal = auth.ComputeUserTier(acctID) + } + postBondRes.Tier = tier + + log.Infof("Bond accepted: acct %v from %v locked %d in %v. Bond total %d, tier %d", + acctID, conn.Addr(), bond.Amount, coinIDString(bond.AssetID, coinID), bondTotal, tier) + + // Respond + resp, err := msgjson.NewResponse(reqID, postBondRes, nil) + if err != nil { // shouldn't be possible + return + } + err = conn.Send(resp) + if err != nil { + log.Warnf("Error sending postbond result to user %v: %v", acctID, err) + if err = auth.Send(acctID, resp); err != nil { + log.Warnf("Error sending feepaid notification to account %v: %v", acctID, err) + // The user will need to either 'connect' to see confirmed status, + // or postbond again. If they reconnected before it was confirmed, + // they must retry postbond until it confirms and is added to the DB + // with their new account. + } + } +} + +// waitBondConfs is a coin waiter that should be started after validating a bond +// transaction in the postbond request handler. This waits for the transaction +// output referenced by coinID to reach reqConfs, and then re-validates the +// amount and address to which the coinID pays. If the checks pass, the account +// is marked as paid in storage by saving the coinID for the accountID. Finally, +// a FeePaidNotification is sent to the provided conn. In case the notification +// fails to send (e.g. connection no longer active), the user should check paid +// status on 'connect'. +func (auth *AuthManager) waitBondConfs(ctx context.Context, conn comms.Link, bond *db.Bond, acct *account.Account, + reqConfs int64, newAcct bool, reqID uint64, postBondRes *msgjson.PostBondResult) wait.TryDirective { + assetID, coinID := bond.AssetID, bond.CoinID + amt, _, confs, _, err := auth.checkBond(ctx, assetID, bond.Version, coinID) + if err != nil { + // This is unexpected because we already validated everything, so + // hopefully this is a transient failure such as RPC connectivity. + log.Warnf("Unexpected error checking bond coin: %v", err) + return wait.TryAgain + } + if confs < reqConfs { + return wait.TryAgain + } + acctID := acct.ID + + // Verify the bond amount as a spot check. This should be redundant with the + // parseBondTx checks. If it disagrees, there is a bug in the fee asset + // backend, and the operator will need to intervene. + if amt != bond.Amount { + log.Errorf("checkFee: account %v fee coin %x pays %d; expected %d", + acctID, coinID, amt, bond.Amount) + return wait.DontTryAgain + } + + // Store and respond + log.Debugf("Activating new bond %s (%s) committing %d for user %v", + coinIDString(assetID, coinID), dex.BipIDSymbol(assetID), amt, acctID) + auth.storeBondAndRespond(conn, bond, acct, newAcct, reqID, postBondRes) + + return wait.DontTryAgain +} + // handleRegister handles requests to the 'register' route. +// +// DEPRECATED (V0PURGE) func (auth *AuthManager) handleRegister(conn comms.Link, msg *msgjson.Message) *msgjson.Error { // Unmarshal. register := new(msgjson.Register) @@ -97,6 +475,12 @@ func (auth *AuthManager) handleRegister(conn comms.Link, msg *msgjson.Message) * ai, err := auth.storage.AccountInfo(acct.ID) if err == nil { if len(ai.FeeCoin) > 0 { + if _, tier := auth.AcctStatus(acct.ID); tier < 1 { + return &msgjson.Error{ + Code: msgjson.AccountSuspendedError, + Message: "account exists and is suspended", // "suspended" - they could upgrade and post bond instead + } + } return &msgjson.Error{ Code: msgjson.AccountExistsError, Message: ai.FeeCoin.String(), // Fee coin TODO: supplement with asset ID @@ -137,20 +521,18 @@ func (auth *AuthManager) handleRegister(conn comms.Link, msg *msgjson.Message) * if err = auth.storage.CreateAccount(acct, regAsset, feeAddr); err != nil { log.Debugf("CreateAccount(%s) failed: %v", acct.ID, err) var archiveErr db.ArchiveError // CreateAccount returns by value. - if errors.As(err, &archiveErr) { - // These cases should have been caught above. - switch archiveErr.Code { - case db.ErrAccountExists: - return &msgjson.Error{ - Code: msgjson.AccountExistsError, - Message: archiveErr.Detail, // Fee coin - } - case db.ErrAccountSuspended: + if errors.As(err, &archiveErr) && archiveErr.Code == db.ErrAccountExists { + // The account exists case would have been caught above. + if _, tier := auth.AcctStatus(acct.ID); tier < 1 { return &msgjson.Error{ Code: msgjson.AccountSuspendedError, Message: "account exists and is suspended", } } + return &msgjson.Error{ + Code: msgjson.AccountExistsError, + Message: archiveErr.Detail, // Fee coin, if it was paid that way + } } return &msgjson.Error{ Code: msgjson.RPCInternalError, @@ -164,6 +546,8 @@ func (auth *AuthManager) handleRegister(conn comms.Link, msg *msgjson.Message) * } // handleNotifyFee handles requests to the 'notifyfee' route. +// +// DEPRECATED (V0PURGE) func (auth *AuthManager) handleNotifyFee(conn comms.Link, msg *msgjson.Message) *msgjson.Error { // Unmarshal. notifyFee := new(msgjson.NotifyFee) @@ -196,12 +580,13 @@ func (auth *AuthManager) handleNotifyFee(conn comms.Link, msg *msgjson.Message) Message: "no account found for ID " + notifyFee.AccountID.String(), } } - if ai.BrokenRule != account.NoRule { - return &msgjson.Error{ - Code: msgjson.AuthenticationError, - Message: "account closed and cannot be reopened", - } - } + // TODO: check tier and respond differently if <1? + // if ai.BrokenRule != account.NoRule { + // return &msgjson.Error{ + // Code: msgjson.AuthenticationError, + // Message: "account closed and cannot be reopened", + // } + // } // Check signature sigMsg := notifyFee.Serialize() @@ -294,8 +679,13 @@ func (auth *AuthManager) handleNotifyFee(conn comms.Link, msg *msgjson.Message) return nil } -// validateFee is a coin waiter that validates a client's notifyFee request and -// responds with an Acknowledgement. +// validateFee is a coin waiter that validates a client's notifyfee request and +// responds with an Acknowledgement when it reaches the required number of +// confirmations (feeConfs). The database's PayAccount method is used to store +// the coinID of the fully-confirmed fee transaction, which is the current +// indicator that the account is paid with a legacy registration fee. +// +// DEPRECATED (V0PURGE) func (auth *AuthManager) validateFee(conn comms.Link, msgID uint64, acctID account.AccountID, notifyFee *msgjson.NotifyFee, assetID uint32, regAddr string) wait.TryDirective { // If there is a problem, respond with an error. diff --git a/server/cmd/dcrdex/config.go b/server/cmd/dcrdex/config.go index 849e9b1164..7afe70743d 100644 --- a/server/cmd/dcrdex/config.go +++ b/server/cmd/dcrdex/config.go @@ -81,7 +81,6 @@ type dexConf struct { RegFeeConfirms int64 RegFeeAmount uint64 CancelThreshold float64 - Anarchy bool FreeCancels bool MaxUserCancels uint32 BanScore uint32 @@ -128,16 +127,14 @@ type flagsData struct { BroadcastTimeout time.Duration `long:"bcasttimeout" description:"The broadcast timeout specifies how long clients have to broadcast an expected transaction when it is their turn to act. Matches without the expected action by this time are revoked and the actor is penalized (default: 12 minutes)."` TxWaitExpiration time.Duration `long:"txwaitexpiration" description:"How long the server will search for a client-reported transaction before responding to the client with an error indicating that it was not found. This should ideally be less than half of swaps BroadcastTimeout to allow for more than one retry of the client's request (default: 2 minutes)."` DEXPrivKeyPath string `long:"dexprivkeypath" description:"The path to a file containing the DEX private key for message signing."` - // Deprecated fields that specify the Decred-specific registration fee // config. This information is now specified per-asset in markets.json. RegFeeXPub string `long:"regfeexpub" description:"DEPRECATED - use markets.json instead. The extended public key for deriving Decred addresses to which DEX registration fees should be paid."` RegFeeConfirms int64 `long:"regfeeconfirms" description:"DEPRECATED - use markets.json instead. The number of confirmations required to consider a registration fee paid."` RegFeeAmount uint64 `long:"regfeeamount" description:"DEPRECATED - use markets.json instead. The registration fee amount in atoms."` - Anarchy bool `long:"anarchy" description:"Do not enforce any rules."` CancelThreshold float64 `long:"cancelthresh" description:"Cancellation rate threshold (cancels/all_completed)."` - FreeCancels bool `long:"freecancels" description:"No cancellation rate enforcement (unlimited cancel orders). Implied by --anarchy."` + FreeCancels bool `long:"freecancels" description:"No cancellation rate enforcement (unlimited cancel orders)."` MaxUserCancels uint32 `long:"maxepochcancels" description:"The maximum number of cancel orders allowed for a user in a given epoch."` BanScore uint32 `long:"banscore" description:"The accumulated penalty score at which when an account gets closed."` InitTakerLotLimit uint32 `long:"inittakerlotlimit" description:"The starting limit on the number of settling lots per-market for new users. Used to limit size of likely-taker orders."` @@ -549,8 +546,7 @@ func loadConfig() (*dexConf, *procOpts, error) { RegFeeXPub: cfg.RegFeeXPub, CancelThreshold: cfg.CancelThreshold, MaxUserCancels: cfg.MaxUserCancels, - Anarchy: cfg.Anarchy, - FreeCancels: cfg.FreeCancels || cfg.Anarchy, + FreeCancels: cfg.FreeCancels, BanScore: cfg.BanScore, InitTakerLotLimit: cfg.InitTakerLotLimit, AbsTakerLotLimit: cfg.AbsTakerLotLimit, diff --git a/server/cmd/dcrdex/main.go b/server/cmd/dcrdex/main.go index 9c87c7f7da..f25c59c863 100644 --- a/server/cmd/dcrdex/main.go +++ b/server/cmd/dcrdex/main.go @@ -156,7 +156,6 @@ func mainCore(ctx context.Context) error { BroadcastTimeout: cfg.BroadcastTimeout, TxWaitExpiration: cfg.TxWaitExpiration, CancelThreshold: cfg.CancelThreshold, - Anarchy: cfg.Anarchy, FreeCancels: cfg.FreeCancels, BanScore: cfg.BanScore, InitTakerLotLimit: cfg.InitTakerLotLimit, diff --git a/server/cmd/dcrdex/sample-dcrdex.conf b/server/cmd/dcrdex/sample-dcrdex.conf index 83d5f100c5..a2e7e08d52 100644 --- a/server/cmd/dcrdex/sample-dcrdex.conf +++ b/server/cmd/dcrdex/sample-dcrdex.conf @@ -142,16 +142,11 @@ ; If not set, dcrdex will prompt "Signing key password:". ; signingkeypass= -; Do not enforce any rules. -; Default is false. -; anarchy=true - ; Cancellation rate threshold (cancels/all_completed). ; Default value is 0.95 - 19 cancels : 1 success ; cancelthresh=0.95 -; No cancellation rate enforcement (unlimited cancel orders). Implied by -; --anarchy. +; No cancellation rate enforcement (unlimited cancel orders). ; Default is false. ; freecancels=true diff --git a/server/cmd/dcrdex/sample-markets.json b/server/cmd/dcrdex/sample-markets.json index f7bcf0ca40..30f0b2cc7d 100644 --- a/server/cmd/dcrdex/sample-markets.json +++ b/server/cmd/dcrdex/sample-markets.json @@ -26,14 +26,20 @@ "configPath": "/home/dcrd/.dcrd/dcrd.conf", "regConfs": 2, "regFee": 10000000, - "regXPub": "xpubdecredonlyadsf" + "regXPub": "xpubdecredonlyadsf", + "bondAmt": 100000000, + "bondConfs": 1 }, "DCR_testnet": { "bip44symbol": "dcr", "network": "testnet", "maxFeeRate": 10, "swapConf": 1, - "configPath": "/home/dcrd/.dcrd/dcrd.conf" + "configPath": "/home/dcrd/.dcrd/dcrd.conf", + "regFee": 10000000, + "regXPub": "tpubdecredonlyadsf", + "bondAmt": 100000000, + "bondConfs": 1 }, "BTC_mainnet": { "bip44symbol": "btc", diff --git a/server/db/driver/pg/accounts.go b/server/db/driver/pg/accounts.go index ddcd28ec98..59e4c33922 100644 --- a/server/db/driver/pg/accounts.go +++ b/server/db/driver/pg/accounts.go @@ -7,6 +7,7 @@ import ( "database/sql" "errors" "fmt" + "time" "decred.org/dcrdex/server/account" "decred.org/dcrdex/server/db" @@ -14,45 +15,32 @@ import ( "github.com/decred/dcrd/dcrutil/v4" // TODO: consider a move to "crypto/sha256" instead of dcrutil.Hash160 ) -// CloseAccount closes the account by setting the value of the rule column to -// the passed rule. -func (a *Archiver) CloseAccount(aid account.AccountID, rule account.Rule) error { - return a.setAccountRule(aid, rule) -} - -// RestoreAccount reopens the account by setting the value of the rule column -// to account.NoRule. -func (a *Archiver) RestoreAccount(aid account.AccountID) error { - return a.setAccountRule(aid, account.NoRule) -} - -// setAccountRule closes or restores the account by setting the value of the -// rule column. -func (a *Archiver) setAccountRule(aid account.AccountID, rule account.Rule) error { - err := setRule(a.db, a.tables.accounts, aid, rule) - if err != nil { - // fatal unless 0 matching rows found. - if !errors.Is(err, errNoRows) { - a.fatalBackendErr(err) - } - return fmt.Errorf("error setting account rule %s: %w", aid, err) +// Account retrieves the account pubkey, active bonds, and if the account has a +// legacy registration fee address and transaction recorded. If the account does +// not exist or there is in an error retrieving any data, a nil *account.Account +// is returned. +func (a *Archiver) Account(aid account.AccountID, bondExpiry time.Time) (acct *account.Account, bonds []*db.Bond, legacy, legacyPaid bool) { + acct, legacy, legacyPaid, err := getAccount(a.db, a.tables.accounts, aid) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, nil, false, false + case err == nil: + default: + log.Errorf("getAccount error: %v", err) + return nil, nil, false, false } - return nil -} -// Account retrieves the account pubkey, whether the account is paid, and -// whether the account is open, in that order. -func (a *Archiver) Account(aid account.AccountID) (*account.Account, bool, bool) { - acct, isPaid, isOpen, err := getAccount(a.db, a.tables.accounts, aid) + bonds, err = getBondsForAccount(a.db, a.tables.bonds, aid, bondExpiry.Unix()) switch { case errors.Is(err, sql.ErrNoRows): - return nil, false, false + bonds = nil case err == nil: default: - log.Errorf("getAccount error: %v", err) - return nil, false, false + log.Errorf("getBondsForAccount error: %v", err) + return nil, nil, false, false } - return acct, isPaid, isOpen + + return acct, bonds, legacy, legacyPaid } // Accounts returns data for all accounts. @@ -68,7 +56,7 @@ func (a *Archiver) Accounts() ([]*db.Account, error) { var feeAsset sql.NullInt32 for rows.Next() { a := new(db.Account) - err = rows.Scan(&a.AccountID, &a.Pubkey, &feeAsset, &feeAddress, &a.FeeCoin, &a.BrokenRule) + err = rows.Scan(&a.AccountID, &a.Pubkey, &feeAsset, &feeAddress, &a.FeeCoin) if err != nil { return nil, err } @@ -84,12 +72,13 @@ func (a *Archiver) Accounts() ([]*db.Account, error) { // AccountInfo returns data for an account. func (a *Archiver) AccountInfo(aid account.AccountID) (*db.Account, error) { + // bondExpiry time.Time and bonds return needed? stmt := fmt.Sprintf(internal.SelectAccountInfo, a.tables.accounts) acct := new(db.Account) var feeAddress sql.NullString var feeAsset sql.NullInt32 if err := a.db.QueryRow(stmt, aid).Scan(&acct.AccountID, &acct.Pubkey, &feeAsset, - &feeAddress, &acct.FeeCoin, &acct.BrokenRule); err != nil { + &feeAddress, &acct.FeeCoin); err != nil { if errors.Is(err, sql.ErrNoRows) { err = db.ArchiveError{Code: db.ErrAccountUnknown} } @@ -102,19 +91,23 @@ func (a *Archiver) AccountInfo(aid account.AccountID) (*db.Account, error) { } // CreateAccount creates an entry for a new account in the accounts table. +// DEPRECATED: See CreateAccountWithBond. (V0PURGE) func (a *Archiver) CreateAccount(acct *account.Account, assetID uint32, regAddr string) error { ai, err := a.AccountInfo(acct.ID) if err == nil { if ai.FeeAddress != regAddr || ai.FeeAsset != assetID { return db.ArchiveError{Code: db.ErrAccountBadFeeInfo} } + // Account exists. Respond with either a fee address, or their (paid) + // fee coin. if len(ai.FeeCoin) == 0 { return nil // fee address and asset match, just unpaid } - if ai.BrokenRule == account.NoRule { - return db.ArchiveError{Code: db.ErrAccountExists, Detail: ai.FeeCoin.String()} - } - return db.ArchiveError{Code: db.ErrAccountSuspended} + return &db.ArchiveError{Code: db.ErrAccountExists, Detail: ai.FeeCoin.String()} + // With tiers, "ErrAccountSuspended" is no more. Instead, the caller + // should check the user's tier to decide if the caller (the legacy + // 'register' handler) should respond with msgjson.AccountSuspendedError + // or msgjson.AccountExistsError. } if !db.IsErrAccountUnknown(err) { log.Errorf("AccountInfo error for ID %s: %v", acct.ID, err) @@ -125,6 +118,43 @@ func (a *Archiver) CreateAccount(acct *account.Account, assetID uint32, regAddr return createAccount(a.db, a.tables.accounts, acct, assetID, regAddr) } +// CreateAccountWithBond creates a new account with a fidelity bond. +func (a *Archiver) CreateAccountWithBond(acct *account.Account, bond *db.Bond) error { + dbTx, err := a.db.BeginTx(a.ctx, nil) + if err != nil { + return err + } + defer func() { + if err == nil || errors.Is(err, sql.ErrTxDone) { + return + } + if errR := dbTx.Rollback(); errR != nil { + log.Errorf("Rollback failed: %v", errR) + } + }() + + err = createAccountForBond(dbTx, a.tables.accounts, acct) + if err != nil { + return err + } + err = addBond(dbTx, a.tables.bonds, acct.ID, bond) + if err != nil { + return err + } + + err = dbTx.Commit() // for the defer + return err +} + +// AddBond stores a new Bond for an existing account. +func (a *Archiver) AddBond(aid account.AccountID, bond *db.Bond) error { + return addBond(a.db, a.tables.bonds, aid, bond) +} + +func (a *Archiver) DeleteBond(assetID uint32, coinID []byte) error { + return deleteBond(a.db, a.tables.bonds, assetID, coinID) +} + // AccountRegAddr retrieves the registration fee address and the corresponding // asset ID created for the the specified account. func (a *Archiver) AccountRegAddr(aid account.AccountID) (string, uint32, error) { @@ -190,7 +220,7 @@ func (a *Archiver) SetKeyIndex(idx uint32, xpub string) error { } // createAccountTables creates the accounts and fee_keys tables. -func createAccountTables(db *sql.DB) error { +func createAccountTables(db sqlQueryExecutor) error { for _, c := range createAccountTableStatements { created, err := createTable(db, publicSchema, c.name) if err != nil { @@ -200,39 +230,32 @@ func createAccountTables(db *sql.DB) error { log.Tracef("Table %s created", c.name) } } - return nil -} -// setRule sets the rule column value. -func setRule(dbe sqlExecutor, tableName string, aid account.AccountID, rule account.Rule) error { - stmt := fmt.Sprintf(internal.CloseAccount, tableName) - N, err := sqlExec(dbe, stmt, rule, aid) - if err != nil { - return err - } - switch N { - case 0: - return errNoRows - case 1: - return nil - default: - return NewDetailedError(errTooManyRows, fmt.Sprint(N, "rows updated instead of 1")) + for _, c := range createBondIndexesStatements { + err := createIndexStmt(db, c.stmt, c.idxName, bondsTableName) + if err != nil { + return err + } } + + return nil } -// getAccount gets the account pubkey, whether the account has been -// registered, and whether the account is still open, in that order. -func getAccount(dbe *sql.DB, tableName string, aid account.AccountID) (*account.Account, bool, bool, error) { - var coinID, pubkey []byte - var assetID sql.NullInt32 - var rule uint8 +// getAccount gets retrieves the account details, including the pubkey, a flag +// indicating if the account was created with a legacy fee address (not a +// fidelity bond), and a flag indicating if that legacy fee was paid. +func getAccount(dbe sqlQueryer, tableName string, aid account.AccountID) (acct *account.Account, legacy, legacyPaid bool, err error) { + var pubkey []byte stmt := fmt.Sprintf(internal.SelectAccount, tableName) - err := dbe.QueryRow(stmt, aid).Scan(&pubkey, &assetID, &coinID, &rule) + err = dbe.QueryRow(stmt, aid).Scan(&pubkey, &legacy, &legacyPaid) + if err != nil { + return + } + acct, err = account.NewAccountFromPubKey(pubkey) if err != nil { - return nil, false, false, err + return } - acct, err := account.NewAccountFromPubKey(pubkey) - return acct, len(coinID) > 1, rule == 0, err + return } // createAccount creates an entry for the account in the accounts table. @@ -242,9 +265,53 @@ func createAccount(dbe sqlExecutor, tableName string, acct *account.Account, fee return err } +// createAccountForBond creates an entry for the account in the accounts table. +func createAccountForBond(dbe sqlExecutor, tableName string, acct *account.Account) error { + stmt := fmt.Sprintf(internal.CreateAccountForBond, tableName) + _, err := dbe.Exec(stmt, acct.ID, acct.PubKey.SerializeCompressed()) + return err +} + +func addBond(dbe sqlExecutor, tableName string, aid account.AccountID, bond *db.Bond) error { + stmt := fmt.Sprintf(internal.AddBond, tableName) + _, err := dbe.Exec(stmt, bond.Version, bond.CoinID, bond.AssetID, aid, + bond.Amount, bond.Strength, bond.LockTime) + return err +} + +func deleteBond(dbe sqlExecutor, tableName string, assetID uint32, coinID []byte) error { + stmt := fmt.Sprintf(internal.DeleteBond, tableName) + _, err := dbe.Exec(stmt, coinID, assetID) + return err +} + +func getBondsForAccount(dbe sqlQueryer, tableName string, acct account.AccountID, bondExpiryTime int64) ([]*db.Bond, error) { + stmt := fmt.Sprintf(internal.SelectActiveBondsForUser, tableName) + rows, err := dbe.Query(stmt, acct, bondExpiryTime) + if err != nil { + return nil, err + } + defer rows.Close() + + var bonds []*db.Bond + for rows.Next() { + var bond db.Bond + err = rows.Scan(&bond.Version, &bond.CoinID, &bond.AssetID, + &bond.Amount, &bond.Strength, &bond.LockTime) + if err != nil { + return nil, err + } + bonds = append(bonds, &bond) + } + if err = rows.Err(); err != nil { + return nil, err + } + return bonds, nil +} + // accountRegAddr gets the registration fee address and its asset ID created for // the specified account. -func accountRegAddr(dbe *sql.DB, tableName string, aid account.AccountID) (string, uint32, error) { +func accountRegAddr(dbe sqlQueryer, tableName string, aid account.AccountID) (string, uint32, error) { var addr string var assetID sql.NullInt32 stmt := fmt.Sprintf(internal.SelectRegAddress, tableName) @@ -256,7 +323,7 @@ func accountRegAddr(dbe *sql.DB, tableName string, aid account.AccountID) (strin } // payAccount sets the registration fee payment details. -func payAccount(dbe *sql.DB, tableName string, aid account.AccountID, coinID []byte) (bool, error) { +func payAccount(dbe sqlExecutor, tableName string, aid account.AccountID, coinID []byte) (bool, error) { stmt := fmt.Sprintf(internal.SetRegOutput, tableName) res, err := dbe.Exec(stmt, coinID, aid) if err != nil { diff --git a/server/db/driver/pg/accounts_online_test.go b/server/db/driver/pg/accounts_online_test.go index c5634bbef0..f59fec3e60 100644 --- a/server/db/driver/pg/accounts_online_test.go +++ b/server/db/driver/pg/accounts_online_test.go @@ -7,6 +7,7 @@ import ( "fmt" "reflect" "testing" + "time" "decred.org/dcrdex/server/account" ) @@ -63,25 +64,30 @@ func TestAccounts(t *testing.T) { assetID, checkAssetID) } + bondExpiryThreshold := time.Now() // todo + // Get the account. It should be unpaid. - acct, paid, _ := archie.Account(tAcctID) - if paid { + acct, bonds, _, legacyFeePaid := archie.Account(tAcctID, bondExpiryThreshold) + if legacyFeePaid { t.Fatalf("account marked as paid before setting tx details") } + if len(bonds) > 0 { + t.Errorf("found unexpected bonds") + } - // Pay the registration fee. + // Pay the legacy registration fee. err = archie.PayAccount(tAcctID, tCoinID) if err != nil { t.Fatalf("error setting registration fee payment details: %v", err) } // The account should not be marked paid. - _, paid, open := archie.Account(tAcctID) - if !paid { + _, bonds, _, legacyFeePaid = archie.Account(tAcctID, bondExpiryThreshold) + if !legacyFeePaid { t.Fatalf("account not marked as paid after setting reg tx details") } - if !open { - t.Fatalf("newly paid account marked as closed") + if len(bonds) > 0 { + t.Errorf("found unexpected bonds") } accts, err := archie.Accounts() @@ -92,8 +98,7 @@ func TestAccounts(t *testing.T) { accts[0].Pubkey.String() != "0204988a498d5d19514b217e872b4dbd1cf071d365c4879e64ed5919881c97eb19" || accts[0].FeeAsset != assetID || accts[0].FeeAddress != "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k" || - accts[0].FeeCoin.String() != "6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005" || - byte(accts[0].BrokenRule) != byte(0) { + accts[0].FeeCoin.String() != "6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005" { t.Fatal("accounts has unexpected data") } @@ -105,24 +110,6 @@ func TestAccounts(t *testing.T) { t.Fatal("error getting account info: actual does not equal expected") } - // Close the account for failure to complete a swap. - if err := archie.CloseAccount(tAcctID, account.FailureToAct); err != nil { - t.Fatalf("error closing account: %v", err) - } - _, _, open = archie.Account(tAcctID) - if open { - t.Fatal("closed account still marked as open") - } - - // Restore the account. - if err = archie.RestoreAccount(tAcctID); err != nil { - t.Fatalf("error opening account: %v", err) - } - _, _, open = archie.Account(tAcctID) - if !open { - t.Fatal("open account still marked as closed") - } - // The Account ID cannot be null. broken_rule has a default value of 0 // and is unexpected to become null. nullAccounts := `UPDATE %s @@ -145,8 +132,7 @@ func TestAccounts(t *testing.T) { if accts[0].AccountID.String() != "0a9912205b2cbab0c25c2de30bda9074de0ae23b065489a99199bad763f102cc" || accts[0].Pubkey.String() != "" || accts[0].FeeAddress != "" || - accts[0].FeeCoin.String() != "" || - byte(accts[0].BrokenRule) != byte(0) { + accts[0].FeeCoin.String() != "" { t.Fatal("accounts has unexpected data") } @@ -172,15 +158,15 @@ func TestWrongAccount(t *testing.T) { t.Fatalf("no error fetching registration address for unknown account") } - acct, paid, open := archie.Account(tAcctID) + acct, bonds, _, legacyFeePaid := archie.Account(tAcctID, time.Now().Add(-3600*time.Hour)) if acct != nil { t.Fatalf("account retrieved for unknown account ID") } - if paid { + if legacyFeePaid { t.Fatalf("unknown account marked as paid") } - if open { - t.Fatalf("unknown account marked as open") + if len(bonds) > 0 { + t.Errorf("found unexpected bonds") } err = archie.PayAccount(tAcctID, tCoinID) diff --git a/server/db/driver/pg/internal/accounts.go b/server/db/driver/pg/internal/accounts.go index 4ab9167c9a..df7a158855 100644 --- a/server/db/driver/pg/internal/accounts.go +++ b/server/db/driver/pg/internal/accounts.go @@ -2,7 +2,7 @@ package internal const ( // CreateFeeKeysTable creates the fee_keys table, which is a small table that - // is used as a persistent child key counter for a master extended public key. + // is used as a persistent child key counter for master extended public key. CreateFeeKeysTable = `CREATE TABLE IF NOT EXISTS %s ( key_hash BYTEA PRIMARY KEY, -- UNIQUE INDEX child INT8 DEFAULT 0 @@ -13,11 +13,40 @@ const ( account_id BYTEA PRIMARY KEY, -- UNIQUE INDEX pubkey BYTEA, fee_asset INT4, -- NOTE upgrade will add this column after broken_rule - fee_address TEXT, - fee_coin BYTEA, - broken_rule INT2 DEFAULT 0 -- TODO: change to banned BOOL + fee_address TEXT, -- DEPRECATED + fee_coin BYTEA -- DEPRECATED );` + CreateBondsTableV0 = `CREATE TABLE IF NOT EXISTS %s ( + version INT2, + bond_coin_id BYTEA, + asset_id INT4, + account_id BYTEA, + amount INT8, -- informative, strength is what matters + strength int4, + lock_time INT8, + PRIMARY KEY (bond_coin_id, asset_id) + );` + CreateBondsTable = CreateBondsTableV0 + + CreateBondsAcctIndexV0 = `CREATE INDEX IF NOT EXISTS %s ON %s (account_id);` + CreateBondsAcctIndex = CreateBondsAcctIndexV0 + + CreateBondsLockTimeIndexV0 = `CREATE INDEX IF NOT EXISTS %s ON %s (lock_time);` + CreateBondsLockTimeIndex = CreateBondsLockTimeIndexV0 + + CreateBondsCoinIDIndexV0 = `CREATE INDEX IF NOT EXISTS %s ON %s (bond_coin_id, asset_id);` + CreateBondsCoinIDIndex = CreateBondsCoinIDIndexV0 + + AddBond = `INSERT INTO %s (version, bond_coin_id, asset_id, account_id, amount, strength, lock_time) + VALUES ($1, $2, $3, $4, $5, $6, $7);` + + DeleteBond = `DELETE FROM %s WHERE bond_coin_id = $1 AND asset_id = $2;` + + SelectActiveBondsForUser = `SELECT version, bond_coin_id, asset_id, amount, strength, lock_time FROM %s + WHERE account_id = $1 AND lock_time >= $2 + ORDER BY lock_time;` + // InsertKeyIfMissing creates an entry for the specified key hash, if it // doesn't already exist. InsertKeyIfMissing = `INSERT INTO %s (key_hash) @@ -40,24 +69,24 @@ const ( // that the account is closed. CloseAccount = `UPDATE %s SET broken_rule = $1 WHERE account_id = $2;` - // SelectAccount gathers account details for the specified account ID. The - // details returned from this query are sufficient to determine 1) whether the - // registration fee has been paid, or 2) whether the account has been closed. - SelectAccount = `SELECT pubkey, fee_asset, fee_coin, broken_rule + // SelectAccount gathers account details for the specified account ID. + SelectAccount = `SELECT pubkey, fee_address IS NOT NULL, fee_coin IS NOT NULL FROM %s WHERE account_id = $1;` // SelectAllAccounts retrieves all accounts. - SelectAllAccounts = `SELECT account_id, pubkey, fee_asset, fee_address, fee_coin, broken_rule FROM %s;` + SelectAllAccounts = `SELECT account_id, pubkey, fee_asset, fee_address, fee_coin FROM %s;` // SelectAccountInfo retrieves all fields for an account. - SelectAccountInfo = `SELECT account_id, pubkey, fee_asset, fee_address, fee_coin, broken_rule FROM %s + SelectAccountInfo = `SELECT account_id, pubkey, fee_asset, fee_address, fee_coin FROM %s WHERE account_id = $1;` // CreateAccount creates an entry for a new account. CreateAccount = `INSERT INTO %s (account_id, pubkey, fee_asset, fee_address) VALUES ($1, $2, $3, $4);` + CreateAccountForBond = `INSERT INTO %s (account_id, pubkey) VALUES ($1, $2);` + // SelectRegAddress fetches the registration fee address for the account. SelectRegAddress = `SELECT fee_asset, fee_address FROM %s WHERE account_id = $1;` diff --git a/server/db/driver/pg/orders.go b/server/db/driver/pg/orders.go index 5c38916ca5..710761f693 100644 --- a/server/db/driver/pg/orders.go +++ b/server/db/driver/pg/orders.go @@ -552,27 +552,6 @@ func (a *Archiver) storeOrder(ord order.Order, epochIdx, epochDur int64, epochGa } } - // If enabled, search all tables for the order to ensure it is not already - // stored somewhere. - // if a.checkedStores { - // var foundStatus pgOrderStatus - // switch ord.Type() { - // case order.MarketOrderType, order.LimitOrderType: - // foundStatus, _, _, err = orderStatus(a.db, ord.ID(), a.dbName, marketSchema) - // case order.CancelOrderType: - // foundStatus, err = cancelOrderStatus(a.db, ord.ID(), a.dbName, marketSchema) - // } - // - // if err == nil { - // return fmt.Errorf("attempted to store a %s order while it exists "+ - // "in another table as %s", pgToMarketStatus(status), pgToMarketStatus(foundStatus)) - // } - // if !db.IsErrOrderUnknown(err) { - // a.fatalBackendErr(err) - // return fmt.Errorf("findOrder failed: %v", err) - // } - // } - // Check for order commitment duplicates. This also covers order ID since // commitment is part of order serialization. Note that it checks ALL // markets, so this may be excessive. This check may be more appropriate in diff --git a/server/db/driver/pg/pg.go b/server/db/driver/pg/pg.go index d65fc51295..0e1397b9ca 100644 --- a/server/db/driver/pg/pg.go +++ b/server/db/driver/pg/pg.go @@ -56,6 +56,7 @@ type Config struct { type archiverTables struct { feeKeys string accounts string + bonds string } // Archiver must implement server/db.DEXArchivist. @@ -151,6 +152,7 @@ func NewArchiverForRead(ctx context.Context, cfg *Config) (*Archiver, error) { tables: archiverTables{ feeKeys: fullTableName(cfg.DBName, publicSchema, feeKeysTableName), accounts: fullTableName(cfg.DBName, publicSchema, accountsTableName), + bonds: fullTableName(cfg.DBName, publicSchema, bondsTableName), }, fatal: make(chan struct{}), }, nil diff --git a/server/db/driver/pg/system.go b/server/db/driver/pg/system.go index 43dc48c45a..fda7dfdc5d 100644 --- a/server/db/driver/pg/system.go +++ b/server/db/driver/pg/system.go @@ -108,6 +108,7 @@ type sqlExecutor interface { type sqlQueryer interface { Query(query string, args ...interface{}) (*sql.Rows, error) + QueryRow(query string, args ...interface{}) *sql.Row } // sqlExec executes the SQL statement string with any optional arguments, and @@ -199,6 +200,16 @@ type sqlQueryExecutor interface { sqlExecutor } +func createIndexStmt(db sqlQueryExecutor, fmtStmt, indexName, fullTableName string) error { + stmt := fmt.Sprintf(fmtStmt, indexName, fullTableName) + log.Debugf("Creating index %q on the %q table.", indexName, fullTableName) + _, err := db.Exec(stmt) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return err +} + // createTableStmt creates a table with the given name using the provided SQL // statement, if it does not already exist. func createTableStmt(db sqlQueryExecutor, fmtStmt, schema, tableName string) (bool, error) { diff --git a/server/db/driver/pg/tables.go b/server/db/driver/pg/tables.go index de68f5d442..3c7fe0ed46 100644 --- a/server/db/driver/pg/tables.go +++ b/server/db/driver/pg/tables.go @@ -18,6 +18,11 @@ const ( metaTableName = "meta" feeKeysTableName = "fee_keys" accountsTableName = "accounts" + bondsTableName = "bonds" + + indexBondsOnAccountName = "idx_bonds_on_acct" + indexBondsOnLockTimeName = "idx_bonds_on_locktime" + indexBondsOnCoinIDName = "idx_bonds_on_coinid" // market schema tables matchesTableName = "matches" @@ -42,6 +47,18 @@ var createDEXTableStatements = []tableStmt{ var createAccountTableStatements = []tableStmt{ {feeKeysTableName, internal.CreateFeeKeysTable}, {accountsTableName, internal.CreateAccountsTable}, + {bondsTableName, internal.CreateBondsTable}, +} + +type indexStmt struct { + idxName string + stmt string +} + +var createBondIndexesStatements = []indexStmt{ + {indexBondsOnAccountName, internal.CreateBondsAcctIndex}, + {indexBondsOnLockTimeName, internal.CreateBondsLockTimeIndex}, + {indexBondsOnCoinIDName, internal.CreateBondsCoinIDIndex}, } var createMarketTableStatements = []tableStmt{ diff --git a/server/db/driver/pg/testhelpers_test.go b/server/db/driver/pg/testhelpers_test.go index 53f4e2e898..c193b41b22 100644 --- a/server/db/driver/pg/testhelpers_test.go +++ b/server/db/driver/pg/testhelpers_test.go @@ -9,7 +9,6 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/order" "decred.org/dcrdex/server/account" - "decred.org/dcrdex/server/account/pki" "github.com/decred/slog" ) @@ -41,7 +40,7 @@ func randomBytes(len int) []byte { } func randomAccountID() account.AccountID { - pk := randomBytes(pki.PubKeySize) // size is not important since it is going to be hashed + pk := randomBytes(account.PubKeySize) // size is not important since it is going to be hashed return account.NewID(pk) } diff --git a/server/db/driver/pg/upgrades.go b/server/db/driver/pg/upgrades.go index 74a9c63e03..27673cd3e0 100644 --- a/server/db/driver/pg/upgrades.go +++ b/server/db/driver/pg/upgrades.go @@ -17,7 +17,7 @@ import ( "decred.org/dcrdex/server/db/driver/pg/internal" ) -const dbVersion = 5 +const dbVersion = 6 // The number of upgrades defined MUST be equal to dbVersion. var upgrades = []func(db *sql.Tx) error{ @@ -43,6 +43,11 @@ var upgrades = []func(db *sql.Tx) error{ // v5 upgrade adds an epoch_gap column to the cancel order tables to // facilitate free cancels. v5Upgrade, + + // v6 upgrade creates the bonds table. A future upgrade may add a new + // old_fee_coin column to the accounts table for when a manual refund is + // processed. + v6Upgrade, } // v1Upgrade adds the schema_version column and removes the state_hash column @@ -319,6 +324,39 @@ func v5Upgrade(tx *sql.Tx) (err error) { return nil } +// v6Upgrade creates the bonds table and its indexes on account_id and lockTime. +func v6Upgrade(tx *sql.Tx) error { + bondsCreated, err := createTableStmt(tx, internal.CreateBondsTableV0, publicSchema, bondsTableName) + if err != nil { + return fmt.Errorf("failed to create bonds table: %w", err) + } + if bondsCreated { + log.Infof("Created new %q table", bondsTableName) + } else { + log.Warnf("Unexpected existing %q table!", bondsTableName) + } + + namespacedBondsTable := publicSchema + "." + bondsTableName + err = createIndexStmt(tx, internal.CreateBondsAcctIndexV0, indexBondsOnAccountName, namespacedBondsTable) + if err != nil { + return fmt.Errorf("failed to index bonds table on account: %w", err) + } + + err = createIndexStmt(tx, internal.CreateBondsLockTimeIndexV0, indexBondsOnLockTimeName, namespacedBondsTable) + if err != nil { + return fmt.Errorf("failed to index bonds table on lock time: %w", err) + } + + // drop the accounts.broken_rule column + namespacedAccountsTable := publicSchema + "." + accountsTableName + _, err = tx.Exec(fmt.Sprintf("ALTER TABLE %s DROP COLUMN IF EXISTS broken_rule;", namespacedAccountsTable)) + if err != nil { + return fmt.Errorf("failed to drop the accounts.broken_rule column: %w", err) + } + + return nil +} + // DBVersion retrieves the database version from the meta table. func DBVersion(db *sql.DB) (ver uint32, err error) { err = db.QueryRow(internal.SelectDBVersion).Scan(&ver) diff --git a/server/db/errors.go b/server/db/errors.go index 102d824d91..2c759887e4 100644 --- a/server/db/errors.go +++ b/server/db/errors.go @@ -55,7 +55,6 @@ const ( ErrOrderNotExecuted ErrUpdateCount ErrAccountExists - ErrAccountSuspended ErrAccountUnknown ErrAccountBadFeeInfo ErrUnknownFeeKey @@ -82,8 +81,6 @@ func (ae ArchiveError) Error() string { desc = "unexpected number of rows updated" case ErrAccountExists: desc = "account already exists" - case ErrAccountSuspended: - desc = "account suspended" case ErrAccountUnknown: desc = "account unknown" case ErrAccountBadFeeInfo: diff --git a/server/db/interface.go b/server/db/interface.go index 5899d371bd..c3a3153e74 100644 --- a/server/db/interface.go +++ b/server/db/interface.go @@ -212,31 +212,71 @@ type OrderArchiver interface { SetOrderCompleteTime(ord order.Order, compTimeMs int64) error } +// Account holds data returned by Accounts. +type Account struct { + AccountID account.AccountID `json:"accountid"` + Pubkey dex.Bytes `json:"pubkey"` + FeeAsset uint32 `json:"feeasset"` + FeeAddress string `json:"feeaddress"` // DEPRECATED + FeeCoin dex.Bytes `json:"feecoin"` // DEPRECATED +} + +// Bond represents a time-locked fidelity bond posted by a user. +type Bond struct { + Version uint16 + AssetID uint32 + CoinID []byte + Amount int64 + Strength uint32 // Amount / + LockTime int64 + + // Will we need to store asset-specific data, like the redeem script for + // UTXO assets or a contract address or bond key for account assets? Or will + // that info be conveyed by Version and CoinID? + // + // Data []byte +} + // AccountArchiver is the interface required for storage and retrieval of all // account data. type AccountArchiver interface { - // CloseAccount closes an account for violating a rule of community conduct. - CloseAccount(account.AccountID, account.Rule) error - - // RestoreAccount opens an account that was previously closed by CloseAccount. - RestoreAccount(account.AccountID) error - - // Account retrieves the account information for the specified account ID. - // The registration fee payment status is returned as well. A nil pointer - // will be returned for unknown or closed accounts. - Account(account.AccountID) (acct *account.Account, paid, open bool) + // Account retrieves the account information for the specified account ID. A + // nil pointer will be returned for unknown or closed accounts. Bond and + // registration fee payment status is returned as well. A bond is active if + // its lockTime is after the lockTimeThresh Time, which should be + // time.Now().Add(bondExpiry). The legacy bool return refers to the legacy + // registration fee system, and legacyPaid indicates if the account has a + // recorded fee coin (paid legacy fee). + Account(acctID account.AccountID, lockTimeThresh time.Time) (acct *account.Account, activeBonds []*Bond, legacy, legacyPaid bool) // CreateAccount stores a new account with an assigned registration address // for a specific asset. The account is considered unpaid until PayAccount - // is used to set the payment transaction details. + // is used to set the payment transaction details. This is intended for use + // with the old registration fee system, since with the bond system, + // accounts are not to be created until a bond transaction is created and + // broadcasted. The account is considered unpaid until PayAccount is used to + // set the registration fee payment details. (V0PURGE) CreateAccount(acct *account.Account, assetID uint32, regAddr string) error - // AccountRegAddr gets the registration fee address and the corresponding - // asset ID for the account. + // CreateAccountWithBond creates a new account with the given bond. This is + // used for the new postbond request protocol. The bond tx should be + // fully-confirmed. + CreateAccountWithBond(acct *account.Account, bond *Bond) error + + // AddBond stores a new Bond, which is uniquely identified by (asset ID, + // coin ID), for an existing account. + AddBond(acct account.AccountID, bond *Bond) error + + // DeleteBond deletes a bond which should generally be expired. + DeleteBond(assetID uint32, coinID []byte) error + + // AccountRegAddr gets any legacy registration fee address and the + // corresponding asset ID for the account. (V0PURGE) AccountRegAddr(account.AccountID) (string, uint32, error) // PayAccount sets the registration fee payment transaction details for the - // account, completing the registration process. + // account, completing the registration process for old fee system. + // (V0PURGE) PayAccount(account.AccountID, []byte) error // Accounts returns data for all accounts. diff --git a/server/db/types.go b/server/db/types.go deleted file mode 100644 index 8df67e98ce..0000000000 --- a/server/db/types.go +++ /dev/null @@ -1,19 +0,0 @@ -// This code is available on the terms of the project LICENSE.md file, -// also available online at https://blueoakcouncil.org/license/1.0.0. - -package db - -import ( - "decred.org/dcrdex/dex" - "decred.org/dcrdex/server/account" -) - -// Account holds data returned by Accounts. -type Account struct { - AccountID account.AccountID `json:"accountid"` - Pubkey dex.Bytes `json:"pubkey"` - FeeAsset uint32 `json:"feeasset,omitempty"` - FeeAddress string `json:"feeaddress"` - FeeCoin dex.Bytes `json:"feecoin"` - BrokenRule account.Rule `json:"brokenrule"` -} diff --git a/server/dex/dex.go b/server/dex/dex.go index 4d3e8f621f..f1b0336ce7 100644 --- a/server/dex/dex.go +++ b/server/dex/dex.go @@ -32,10 +32,11 @@ import ( const ( // PreAPIVersion covers all API iterations before versioning started. - PreAPIVersion = iota + PreAPIVersion = iota + BondAPIVersion // when we drop the legacy reg fee proto // APIVersion is the current API version. - APIVersion = PreAPIVersion + APIVersion = PreAPIVersion // only advance server to BondAPIVersion with the V0PURGE ) // AssetConf is like dex.Asset except it lacks the BIP44 integer ID and @@ -52,6 +53,8 @@ type AssetConf struct { RegFee uint64 `json:"regFee,omitempty"` RegConfs uint32 `json:"regConfs,omitempty"` RegXPub string `json:"regXPub,omitempty"` + BondAmt uint64 `json:"bondAmt,omitempty"` + BondConfs uint32 `json:"bondConfs,omitempty"` } // DBConf groups the database configuration parameters. @@ -78,7 +81,6 @@ type DexConf struct { BroadcastTimeout time.Duration TxWaitExpiration time.Duration CancelThreshold float64 - Anarchy bool FreeCancels bool BanScore uint32 InitTakerLotLimit uint32 @@ -139,23 +141,27 @@ type configResponse struct { configEnc json.RawMessage } -func newConfigResponse(cfg *DexConf, regAssets map[string]*msgjson.FeeAsset, cfgAssets []*msgjson.Asset, cfgMarkets []*msgjson.Market) (*configResponse, error) { +func newConfigResponse(cfg *DexConf, regAssets map[string]*msgjson.FeeAsset, bondAssets map[string]*msgjson.BondAsset, + cfgAssets []*msgjson.Asset, cfgMarkets []*msgjson.Market) (*configResponse, error) { dcrAsset := regAssets["dcr"] if dcrAsset == nil { return nil, fmt.Errorf("DCR is required as a fee asset for backward compatibility") } configMsg := &msgjson.ConfigResult{ + APIVersion: uint16(APIVersion), + DEXPubKey: cfg.DEXPrivKey.PubKey().SerializeCompressed(), BroadcastTimeout: uint64(cfg.BroadcastTimeout.Milliseconds()), - RegFeeConfirms: uint16(dcrAsset.Confs), // DEPRECATED - DCR only CancelMax: cfg.CancelThreshold, Assets: cfgAssets, Markets: cfgMarkets, - Fee: dcrAsset.Amt, // DEPRECATED - DCR only - APIVersion: uint16(APIVersion), + BondAssets: bondAssets, + BondExpiry: uint64(dex.BondExpiry(cfg.Network)), // temporary while we figure it out BinSizes: candles.BinSizes, - DEXPubKey: cfg.DEXPrivKey.PubKey().SerializeCompressed(), RegFees: regAssets, + + RegFeeConfirms: uint16(dcrAsset.Confs), // DEPRECATED - DCR only (V0PURGE) + Fee: dcrAsset.Amt, // DEPRECATED - DCR only } // NOTE/TODO: To include active epoch in the market status objects, we need @@ -240,6 +246,16 @@ type FeeCoiner interface { FeeCoin(coinID []byte) (addr string, val uint64, confs int64, err error) } +// Bonder describes a type that supports parsing raw bond transactions and +// locating them on-chain via coin ID. +type Bonder interface { + BondVer() uint16 + BondCoin(ctx context.Context, ver uint16, coinID []byte) (amt, lockTime, confs int64, + acct account.AccountID, err error) + ParseBondTx(ver uint16, rawTx []byte) (bondCoinID []byte, amt int64, bondAddr string, + bondPubKeyHash []byte, lockTime int64, acct account.AccountID, err error) +} + // NewDEX creates the dex manager and starts all subsystems. Use Stop to // shutdown cleanly. The Context is used to abort setup. // 1. Validate each specified asset. @@ -252,11 +268,6 @@ type FeeCoiner interface { // 8. Create and start the book router, and create the order router. // 9. Create and start the comms server. func NewDEX(ctx context.Context, cfg *DexConf) (*DEX, error) { - // Disallow running without user penalization in a mainnet config. - if cfg.Anarchy && cfg.Network == dex.Mainnet { - return nil, fmt.Errorf("user penalties may not be disabled on mainnet") - } - var subsystems []subsystem startSubSys := func(name string, rc interface{}) (err error) { subsys := subsystem{name: name} @@ -388,6 +399,8 @@ func NewDEX(ctx context.Context, cfg *DexConf) (*DEX, error) { feeCoiners := make(map[uint32]FeeCoiner) feeAddressers := make(map[uint32]asset.Addresser) xpubs := make(map[string]string) // to enforce uniqueness + bondAssets := make(map[string]*msgjson.BondAsset) + bonders := make(map[uint32]Bonder) // Start asset backends. lockableAssets := make(map[uint32]*swap.SwapperAsset, len(cfg.Assets)) @@ -436,6 +449,23 @@ func NewDEX(ctx context.Context, cfg *DexConf) (*DEX, error) { return fmt.Errorf("failed to start asset %q: %w", symbol, err) } + if assetConf.BondAmt > 0 && assetConf.BondConfs > 0 { + // Make sure we can check on fee transactions. + bc, ok := be.(Bonder) + if !ok { + return fmt.Errorf("asset %v is not a Bonder", symbol) + } + bondAssets[symbol] = &msgjson.BondAsset{ + Version: bc.BondVer(), + ID: assetID, + Amt: assetConf.BondAmt, + Confs: assetConf.BondConfs, + } + bonders[assetID] = bc + log.Infof("Bonds accepted using %s: amount %d, confs %d", + symbol, assetConf.RegFee, assetConf.RegConfs) + } + if assetConf.RegFee > 0 { // Make sure we can check on fee transactions. fc, ok := be.(FeeCoiner) @@ -578,16 +608,40 @@ func NewDEX(ctx context.Context, cfg *DexConf) (*DEX, error) { return fc.FeeCoin(coinID) } + bondChecker := func(ctx context.Context, assetID uint32, version uint16, coinID []byte) (amt, lockTime, confs int64, + acct account.AccountID, err error) { + bc := bonders[assetID] + if bc == nil { + err = fmt.Errorf("unsupported bond asset") + return + } + return bc.BondCoin(ctx, version, coinID) + } + + bondTxParser := func(assetID uint32, version uint16, rawTx []byte) (bondCoinID []byte, + amt, lockTime int64, acct account.AccountID, err error) { + bc := bonders[assetID] + if bc == nil { + err = fmt.Errorf("unsupported bond asset") + return + } + bondCoinID, amt, _, _, lockTime, acct, err = bc.ParseBondTx(version, rawTx) + return + } + authCfg := auth.Config{ Storage: storage, Signer: signer{cfg.DEXPrivKey}, FeeAddress: feeAddresser, FeeAssets: feeAssets, FeeChecker: feeChecker, + BondAssets: bondAssets, + BondTxParser: bondTxParser, + BondChecker: bondChecker, + BondExpiry: uint64(dex.BondExpiry(cfg.Network)), UserUnbooker: userUnbookFun, MiaUserTimeout: cfg.BroadcastTimeout, CancelThreshold: cfg.CancelThreshold, - Anarchy: cfg.Anarchy, FreeCancels: cfg.FreeCancels, BanScore: cfg.BanScore, InitTakerLotLimit: cfg.InitTakerLotLimit, @@ -773,7 +827,7 @@ func NewDEX(ctx context.Context, cfg *DexConf) (*DEX, error) { return nil, fmt.Errorf("NewServer failed: %w", err) } - cfgResp, err := newConfigResponse(cfg, feeAssets, cfgAssets, cfgMarkets) + cfgResp, err := newConfigResponse(cfg, feeAssets, bondAssets, cfgAssets, cfgMarkets) if err != nil { return nil, err } @@ -998,6 +1052,8 @@ func (dm *DEX) Accounts() ([]*db.Account, error) { // AccountInfo returns data for an account. func (dm *DEX) AccountInfo(aid account.AccountID) (*db.Account, error) { + // TODO: consider asking the auth manager for account info, including tier. + // connected, tier := dm.authMgr.AcctStatus(aid) return dm.storage.AccountInfo(aid) } diff --git a/server/market/market.go b/server/market/market.go index 7b96a3babd..ed8cfb3151 100644 --- a/server/market/market.go +++ b/server/market/market.go @@ -1700,8 +1700,8 @@ func (m *Market) processOrder(rec *orderRecord, epoch *EpochQueue, notifyChan ch // Disallow trade orders from suspended accounts. Cancel orders are allowed. if rec.order.Type() != order.CancelOrderType { // Do not bother the auth manager for cancel orders. - if _, suspended := m.auth.Suspended(rec.order.User()); suspended { - log.Debugf("Account %v not allowed to submit order %v", rec.order.User(), rec.order.ID()) + if _, tier := m.auth.AcctStatus(rec.order.User()); tier < 1 { + log.Debugf("Account %v with tier %d not allowed to submit order %v", rec.order.User(), tier, rec.order.ID()) errChan <- ErrSuspendedAccount return nil } diff --git a/server/market/orderrouter.go b/server/market/orderrouter.go index 599a8faabf..cdd9cb6cfb 100644 --- a/server/market/orderrouter.go +++ b/server/market/orderrouter.go @@ -28,7 +28,7 @@ import ( type AuthManager interface { Route(route string, handler func(account.AccountID, *msgjson.Message) *msgjson.Error) Auth(user account.AccountID, msg, sig []byte) error - Suspended(user account.AccountID) (found, suspended bool) + AcctStatus(user account.AccountID) (connected bool, tier int64) Sign(...msgjson.Signable) Send(account.AccountID, *msgjson.Message) error Request(account.AccountID, *msgjson.Message, func(comms.Link, *msgjson.Message)) error @@ -218,8 +218,8 @@ func (r *OrderRouter) handleLimit(user account.AccountID, msg *msgjson.Message) return rpcErr } - if _, suspended := r.auth.Suspended(user); suspended { - return msgjson.NewError(msgjson.MarketNotRunningError, "suspended account %v may not submit trade orders", user) + if _, tier := r.auth.AcctStatus(user); tier < 1 { + return msgjson.NewError(msgjson.AccountClosedError, "account %v with tier %d may not submit trade orders", user, tier) } tunnel, assets, sell, rpcErr := r.extractMarketDetails(&limit.Prefix, &limit.Trade) @@ -235,7 +235,8 @@ func (r *OrderRouter) handleLimit(user account.AccountID, msg *msgjson.Message) // Check that OrderType is set correctly if limit.OrderType != msgjson.LimitOrderNum { - return msgjson.NewError(msgjson.OrderParameterError, "wrong order type set for limit order. wanted %d, got %d", msgjson.LimitOrderNum, limit.OrderType) + return msgjson.NewError(msgjson.OrderParameterError, "wrong order type set for limit order. wanted %d, got %d", + msgjson.LimitOrderNum, limit.OrderType) } // Check that the rate is non-zero and obeys the rate step interval. @@ -326,8 +327,8 @@ func (r *OrderRouter) handleMarket(user account.AccountID, msg *msgjson.Message) return rpcErr } - if _, suspended := r.auth.Suspended(user); suspended { - return msgjson.NewError(msgjson.MarketNotRunningError, "suspended account %v may not submit trade orders", user) + if _, tier := r.auth.AcctStatus(user); tier < 1 { + return msgjson.NewError(msgjson.AccountClosedError, "account %v with tier %d may not submit trade orders", user, tier) } tunnel, assets, sell, rpcErr := r.extractMarketDetails(&market.Prefix, &market.Trade) diff --git a/server/market/routers_test.go b/server/market/routers_test.go index 9a99ea121e..d71278ec5e 100644 --- a/server/market/routers_test.go +++ b/server/market/routers_test.go @@ -247,7 +247,9 @@ func (a *TAuth) Inaction(user account.AccountID, step auth.NoActionStep, mmid db func (a *TAuth) UserSettlingLimit(user account.AccountID, mkt *dex.MarketInfo) int64 { return dcrLotSize * initLotLimit // everyone gets a clean slate } - +func (a *TAuth) AcctStatus(user account.AccountID) (connected bool, tier int64) { + return true, 1 +} func (a *TAuth) RecordCompletedOrder(account.AccountID, order.OrderID, time.Time) {} func (a *TAuth) RecordCancel(aid account.AccountID, coid, oid order.OrderID, epochGap int32, t time.Time) { a.cancelOrder = coid