decred.org/dcrdex@v1.0.5/server/auth/registrar.go (about)

     1  // This code is available on the terms of the project LICENSE.md file,
     2  // also available online at https://blueoakcouncil.org/license/1.0.0.
     3  
     4  package auth
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"fmt"
    10  	"time"
    11  
    12  	"decred.org/dcrdex/dex"
    13  	"decred.org/dcrdex/dex/encode"
    14  	"decred.org/dcrdex/dex/msgjson"
    15  	"decred.org/dcrdex/dex/wait"
    16  	"decred.org/dcrdex/server/account"
    17  	"decred.org/dcrdex/server/comms"
    18  	"decred.org/dcrdex/server/db"
    19  )
    20  
    21  var (
    22  	// The coin waiters will query for transaction data every recheckInterval.
    23  	recheckInterval = time.Second * 5
    24  	// txWaitExpiration is the longest the AuthManager will wait for a coin
    25  	// waiter. This could be thought of as the maximum allowable backend latency.
    26  	txWaitExpiration = 2 * time.Minute
    27  )
    28  
    29  // bondKey creates a unique map key for a bond by its asset ID and coin ID.
    30  func bondKey(assetID uint32, coinID []byte) string {
    31  	return string(append(encode.Uint32Bytes(assetID), coinID...))
    32  }
    33  
    34  func (auth *AuthManager) registerBondWaiter(key string) bool {
    35  	auth.bondWaiterMtx.Lock()
    36  	defer auth.bondWaiterMtx.Unlock()
    37  	if _, found := auth.bondWaiterIdx[key]; found {
    38  		return false
    39  	}
    40  	auth.bondWaiterIdx[key] = struct{}{}
    41  	return true
    42  }
    43  
    44  func (auth *AuthManager) removeBondWaiter(key string) {
    45  	auth.bondWaiterMtx.Lock()
    46  	delete(auth.bondWaiterIdx, key)
    47  	auth.bondWaiterMtx.Unlock()
    48  }
    49  
    50  // handlePreValidateBond handles the 'prevalidatebond' request.
    51  //
    52  // The request payload includes the user's account public key and the serialized
    53  // bond post transaction itself (not just the txid).
    54  //
    55  // The parseBondTx function is used to validate the transaction, and extract
    56  // bond details (amount and lock time) and the account ID to which it commits.
    57  // This also checks that the account commitment corresponds to the user's public
    58  // key provided in the payload. If these requirements are satisfied, the client
    59  // will receive a PreValidateBondResult in the response. The user should then
    60  // proceed to broadcast the bond and use the 'postbond' route once it reaches
    61  // the required number of confirmations.
    62  func (auth *AuthManager) handlePreValidateBond(conn comms.Link, msg *msgjson.Message) *msgjson.Error {
    63  	preBond := new(msgjson.PreValidateBond)
    64  	err := msg.Unmarshal(&preBond)
    65  	if err != nil || preBond == nil {
    66  		return msgjson.NewError(msgjson.BondError, "error parsing prevalidatebond request")
    67  	}
    68  
    69  	assetID := preBond.AssetID
    70  	bondAsset, ok := auth.bondAssets[assetID]
    71  	if !ok {
    72  		return msgjson.NewError(msgjson.BondError, "%s does not support bonds", dex.BipIDSymbol(assetID))
    73  	}
    74  
    75  	// Create an account.Account from the provided pubkey.
    76  	acct, err := account.NewAccountFromPubKey(preBond.AcctPubKey)
    77  	if err != nil {
    78  		return msgjson.NewError(msgjson.BondError, "error parsing account pubkey: %v", err)
    79  	}
    80  	acctID := acct.ID
    81  
    82  	// Authenticate the message for the supposed account.
    83  	sigMsg := preBond.Serialize()
    84  	err = checkSigS256(sigMsg, preBond.SigBytes(), acct.PubKey)
    85  	if err != nil {
    86  		return &msgjson.Error{
    87  			Code:    msgjson.SignatureError,
    88  			Message: "signature error: " + err.Error(),
    89  		}
    90  	}
    91  
    92  	// A bond's lockTime must be after bondExpiry from now.
    93  	lockTimeThresh := time.Now().Add(auth.bondExpiry)
    94  
    95  	// Decode raw tx, check fee output (0) and account commitment output (1).
    96  	bondCoinID, amt, lockTime, commitAcct, err :=
    97  		auth.parseBondTx(assetID, preBond.Version, preBond.RawTx /*, postBond.Data*/)
    98  	if err != nil {
    99  		return msgjson.NewError(msgjson.BondError, "invalid bond transaction: %v", err)
   100  	}
   101  	if amt < int64(bondAsset.Amt) {
   102  		return msgjson.NewError(msgjson.BondError, "insufficient bond amount %d, needed %d", amt, bondAsset.Amt)
   103  	}
   104  	if lockTime < lockTimeThresh.Unix() {
   105  		return msgjson.NewError(msgjson.BondError, "insufficient lock time %d, needed at least %d", lockTime, lockTimeThresh.Unix())
   106  	}
   107  
   108  	// Must be equal to account ID computed from pubkey in the PayFee message.
   109  	if commitAcct != acctID {
   110  		return msgjson.NewError(msgjson.BondError, "invalid bond transaction - account commitment does not match pubkey")
   111  	}
   112  
   113  	bondStr := coinIDString(assetID, bondCoinID)
   114  	bondAssetSym := dex.BipIDSymbol(assetID)
   115  	log.Debugf("Validated prospective bond txn output %s (%s) paying %d for user %v",
   116  		bondStr, bondAssetSym, amt, acctID)
   117  
   118  	expireTime := time.Unix(lockTime, 0).Add(-auth.bondExpiry)
   119  	preBondRes := &msgjson.PreValidateBondResult{
   120  		AccountID: acctID[:],
   121  		AssetID:   assetID,
   122  		Amount:    uint64(amt),
   123  		Expiry:    uint64(expireTime.Unix()),
   124  	}
   125  	preBondRes.SetSig(auth.SignMsg(append(preBondRes.Serialize(), preBond.RawTx...)))
   126  
   127  	resp, err := msgjson.NewResponse(msg.ID, preBondRes, nil)
   128  	if err != nil { // shouldn't be possible
   129  		return msgjson.NewError(msgjson.RPCInternalError, "internal encoding error")
   130  	}
   131  	err = conn.Send(resp)
   132  	if err != nil {
   133  		log.Warnf("Error sending prevalidatebond result to user %v: %v", acctID, err)
   134  		if err = auth.Send(acctID, resp); err != nil {
   135  			log.Warnf("Error sending prevalidatebond result to account %v: %v", acctID, err)
   136  		}
   137  	}
   138  	return nil
   139  }
   140  
   141  // handlePostBond handles the 'postbond' request.
   142  //
   143  // The checkBond function is used to locate the bond transaction on the network,
   144  // and verify the amount, lockTime, and account to which it commits.
   145  //
   146  // A 'postbond' request should not be made until the bond transaction has been
   147  // broadcasted and reaches the required number of confirmations.
   148  func (auth *AuthManager) handlePostBond(conn comms.Link, msg *msgjson.Message) *msgjson.Error {
   149  	postBond := new(msgjson.PostBond)
   150  
   151  	err := msg.Unmarshal(&postBond)
   152  	if err != nil || postBond == nil {
   153  		return msgjson.NewError(msgjson.BondError, "error parsing postbond request")
   154  	}
   155  
   156  	assetID := postBond.AssetID
   157  	bondAsset, ok := auth.bondAssets[assetID]
   158  	if !ok && assetID != account.PrepaidBondID {
   159  		return msgjson.NewError(msgjson.BondError, "%s does not support bonds", dex.BipIDSymbol(assetID))
   160  	}
   161  
   162  	// Create an account.Account from the provided pubkey.
   163  	acct, err := account.NewAccountFromPubKey(postBond.AcctPubKey)
   164  	if err != nil {
   165  		return msgjson.NewError(msgjson.BondError, "error parsing account pubkey: %v", err)
   166  	}
   167  	acctID := acct.ID
   168  
   169  	// Authenticate the message for the supposed account.
   170  	sigMsg := postBond.Serialize()
   171  	err = checkSigS256(sigMsg, postBond.SigBytes(), acct.PubKey)
   172  	if err != nil {
   173  		return &msgjson.Error{
   174  			Code:    msgjson.SignatureError,
   175  			Message: "signature error: " + err.Error(),
   176  		}
   177  	}
   178  
   179  	if assetID == account.PrepaidBondID {
   180  		return auth.processPrepaidBond(conn, msg, acct, postBond.CoinID)
   181  	}
   182  
   183  	// A bond's lockTime must be after bondExpiry from now.
   184  	lockTimeThresh := time.Now().Add(auth.bondExpiry)
   185  
   186  	bondVer, bondCoinID := postBond.Version, postBond.CoinID
   187  	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
   188  	defer cancel()
   189  	amt, lockTime, confs, commitAcct, err := auth.checkBond(ctx, assetID, bondVer, bondCoinID)
   190  	if err != nil {
   191  		return msgjson.NewError(msgjson.BondError, "invalid bond transaction: %v", err)
   192  	}
   193  	if amt < int64(bondAsset.Amt) {
   194  		return msgjson.NewError(msgjson.BondError, "insufficient bond amount %d, needed %d", amt, bondAsset.Amt)
   195  	}
   196  	if lockTime < lockTimeThresh.Unix() {
   197  		return msgjson.NewError(msgjson.BondError, "insufficient lock time %d, needed at least %d", lockTime, lockTimeThresh.Unix())
   198  	}
   199  
   200  	// Must be equal to account ID computed from pubkey in the PayFee message.
   201  	if commitAcct != acctID {
   202  		return msgjson.NewError(msgjson.BondError, "invalid bond transaction - account commitment does not match pubkey")
   203  	}
   204  
   205  	strength := uint32(uint64(amt) / bondAsset.Amt)
   206  
   207  	// All good. The client gets a PostBondResult (no error) unless the confirms
   208  	// check has an unexpected error or times out.
   209  	expireTime := time.Unix(lockTime, 0).Add(-auth.bondExpiry)
   210  	postBondRes := &msgjson.PostBondResult{
   211  		AccountID:  acctID[:],
   212  		AssetID:    assetID,
   213  		Amount:     uint64(amt),
   214  		Expiry:     uint64(expireTime.Unix()),
   215  		Strength:   strength,
   216  		BondID:     bondCoinID,
   217  		Reputation: auth.ComputeUserReputation(acctID),
   218  	}
   219  	auth.Sign(postBondRes)
   220  
   221  	sendResp := func() *msgjson.Error {
   222  		resp, err := msgjson.NewResponse(msg.ID, postBondRes, nil)
   223  		if err != nil { // shouldn't be possible
   224  			return msgjson.NewError(msgjson.RPCInternalError, "internal encoding error")
   225  		}
   226  		err = conn.Send(resp)
   227  		if err != nil {
   228  			log.Warnf("Error sending postbond result to user %v: %v", acctID, err)
   229  			if err = auth.Send(acctID, resp); err != nil {
   230  				log.Warnf("Error sending postbond result to account %v: %v", acctID, err)
   231  				// The user will need to 'connect' to reconcile bond status.
   232  			}
   233  		}
   234  		return nil
   235  	}
   236  
   237  	// See if the account exists, and get known unexpired bonds. Also see if the
   238  	// account has previously paid a legacy registration fee.
   239  	dbAcct, bonds := auth.storage.Account(acctID, lockTimeThresh)
   240  
   241  	bondStr := coinIDString(assetID, bondCoinID)
   242  	bondAssetSym := dex.BipIDSymbol(assetID)
   243  
   244  	// See if we already have this bond in DB.
   245  	for _, bond := range bonds {
   246  		if bond.AssetID == assetID && bytes.Equal(bond.CoinID, bondCoinID) {
   247  			log.Debugf("Found existing bond %s (%s) committing %d for user %v",
   248  				bondStr, bondAssetSym, amt, acctID)
   249  			return sendResp()
   250  		}
   251  	}
   252  
   253  	dbBond := &db.Bond{
   254  		Version:  postBond.Version,
   255  		AssetID:  assetID,
   256  		CoinID:   bondCoinID,
   257  		Amount:   amt,
   258  		Strength: strength,
   259  		LockTime: lockTime,
   260  	}
   261  
   262  	// Either store the bond or start a block waiter to activate the bond and
   263  	// respond with a PostBondResult when it is fully-confirmed.
   264  	bondIDKey := bondKey(assetID, bondCoinID)
   265  	if !auth.registerBondWaiter(bondIDKey) {
   266  		// Waiter already running! They'll get a response to their first
   267  		// request, or find out on connect if the bond was activated.
   268  		return msgjson.NewError(msgjson.BondAlreadyConfirmingError, "bond already submitted")
   269  	}
   270  
   271  	newAcct := dbAcct == nil
   272  	reqConfs := int64(bondAsset.Confs)
   273  
   274  	if confs >= reqConfs {
   275  		// No need to call checkFee again in a waiter.
   276  		log.Debugf("Activating new bond %s (%s) committing %d for user %v", bondStr, bondAssetSym, amt, acctID)
   277  		auth.storeBondAndRespond(conn, dbBond, acct, newAcct, msg.ID, postBondRes)
   278  		auth.removeBondWaiter(bondIDKey) // after storing it
   279  		return nil
   280  	}
   281  
   282  	// The user should have submitted only when the bond was confirmed, so we
   283  	// only expect to wait for asset network latency.
   284  	log.Debugf("Found new bond %s (%s) committing %d for user %v. Confirming...",
   285  		bondStr, bondAssetSym, amt, acctID)
   286  	ctxTry, cancelTry := context.WithTimeout(context.Background(), txWaitExpiration) // prevent checkBond RPC hangs
   287  	auth.latencyQ.Wait(&wait.Waiter{
   288  		Expiration: time.Now().Add(txWaitExpiration),
   289  		TryFunc: func() wait.TryDirective {
   290  			res := auth.waitBondConfs(ctxTry, conn, dbBond, acct, reqConfs, newAcct, msg.ID, postBondRes)
   291  			if res == wait.DontTryAgain {
   292  				auth.removeBondWaiter(bondIDKey)
   293  				cancelTry()
   294  			}
   295  			return res
   296  		},
   297  		ExpireFunc: func() {
   298  			auth.removeBondWaiter(bondIDKey)
   299  			cancelTry()
   300  			// User may retry postbond periodically or on reconnect.
   301  		},
   302  	})
   303  	// NOTE: server restart cannot restart these waiters, so user must resubmit
   304  	// their postbond after their request times out.
   305  
   306  	return nil
   307  }
   308  
   309  func (auth *AuthManager) storeBondAndRespond(conn comms.Link, bond *db.Bond, acct *account.Account,
   310  	newAcct bool, reqID uint64, postBondRes *msgjson.PostBondResult) {
   311  	acctID := acct.ID
   312  	assetID, coinID := bond.AssetID, bond.CoinID
   313  	bondStr := coinIDString(assetID, coinID)
   314  	bondAssetSym := dex.BipIDSymbol(assetID)
   315  	var err error
   316  	if newAcct {
   317  		log.Infof("Creating new user account %v from %v, posted first bond in %v (%s)",
   318  			acctID, conn.Addr(), bondStr, bondAssetSym)
   319  		err = auth.storage.CreateAccountWithBond(acct, bond)
   320  	} else {
   321  		log.Infof("Adding bond for existing user account %v from %v, with bond in %v (%s)",
   322  			acctID, conn.Addr(), bondStr, bondAssetSym)
   323  		err = auth.storage.AddBond(acct.ID, bond)
   324  	}
   325  	if err != nil {
   326  		log.Errorf("Failure while storing bond for acct %v (new = %v): %v", acct, newAcct, err)
   327  		conn.SendError(reqID, &msgjson.Error{
   328  			Code:    msgjson.RPCInternalError,
   329  			Message: "failed to store bond",
   330  		})
   331  		return
   332  	}
   333  
   334  	// Integrate active bonds and score to report tier.
   335  	rep := auth.addBond(acctID, bond)
   336  	if rep == nil { // user not authenticated, use DB
   337  		rep = auth.ComputeUserReputation(acctID)
   338  	}
   339  	postBondRes.Reputation = rep
   340  
   341  	log.Infof("Bond accepted: acct %v from %v locked %d in %v. Bond total %d, tier %d",
   342  		acctID, conn.Addr(), bond.Amount, coinIDString(bond.AssetID, coinID), rep.BondedTier, rep.EffectiveTier())
   343  
   344  	// Respond
   345  	resp, err := msgjson.NewResponse(reqID, postBondRes, nil)
   346  	if err != nil { // shouldn't be possible
   347  		return
   348  	}
   349  	err = conn.Send(resp)
   350  	if err != nil {
   351  		log.Warnf("Error sending prepaid bond result to user %v: %v", acctID, err)
   352  		if err = auth.Send(acctID, resp); err != nil {
   353  			log.Warnf("Error sending feepaid notification to account %v: %v", acctID, err)
   354  			// The user will need to either 'connect' to see confirmed status,
   355  			// or postbond again. If they reconnected before it was confirmed,
   356  			// they must retry postbond until it confirms and is added to the DB
   357  			// with their new account.
   358  		}
   359  	}
   360  }
   361  
   362  func (auth *AuthManager) processPrepaidBond(conn comms.Link, msg *msgjson.Message, acct *account.Account, coinID []byte) *msgjson.Error {
   363  	auth.prepaidBondMtx.Lock()
   364  	defer auth.prepaidBondMtx.Unlock()
   365  	strength, lockTimeI, err := auth.storage.FetchPrepaidBond(coinID)
   366  	if err != nil {
   367  		return msgjson.NewError(msgjson.BondError, "unknown or already spent pre-paid bond: %v", err)
   368  	}
   369  
   370  	lockTime := time.Unix(lockTimeI, 0)
   371  	expireTime := lockTime.Add(-auth.bondExpiry)
   372  	if time.Until(expireTime) < time.Hour*24 {
   373  		return msgjson.NewError(msgjson.BondError, "pre-paid bond is too old")
   374  	}
   375  
   376  	postBondRes := &msgjson.PostBondResult{
   377  		AccountID:  acct.ID[:],
   378  		AssetID:    account.PrepaidBondID,
   379  		Amount:     0,
   380  		Strength:   strength,
   381  		Expiry:     uint64(expireTime.Unix()),
   382  		BondID:     coinID,
   383  		Reputation: auth.ComputeUserReputation(acct.ID),
   384  	}
   385  	auth.Sign(postBondRes)
   386  
   387  	lockTimeThresh := time.Now().Add(auth.bondExpiry)
   388  	dbAcct, _ := auth.storage.Account(acct.ID, lockTimeThresh)
   389  
   390  	dbBond := &db.Bond{
   391  		AssetID:  account.PrepaidBondID,
   392  		CoinID:   coinID,
   393  		Strength: strength,
   394  		LockTime: lockTimeI,
   395  	}
   396  
   397  	newAcct := dbAcct == nil
   398  	if newAcct {
   399  		log.Infof("Creating new user account %s from pre-paid bond. addr = %s", acct.ID, conn.Addr())
   400  		err = auth.storage.CreateAccountWithBond(acct, dbBond)
   401  	} else {
   402  		log.Infof("Adding pre-bond for existing user account %v, addr = %s", acct.ID, conn.Addr())
   403  		err = auth.storage.AddBond(acct.ID, dbBond)
   404  	}
   405  	if err != nil {
   406  		log.Errorf("Failure while storing pre-paid bond for acct %v (new = %v): %v", acct.ID, newAcct, err)
   407  		return &msgjson.Error{
   408  			Code:    msgjson.RPCInternalError,
   409  			Message: "failed to store pre-paid bond",
   410  		}
   411  	}
   412  
   413  	if err := auth.storage.DeletePrepaidBond(coinID); err != nil {
   414  		log.Errorf("Error deleting pre-paid bond id = %s from database: %v", dex.Bytes(coinID), err)
   415  	}
   416  
   417  	rep := auth.addBond(acct.ID, dbBond)
   418  	if rep == nil { // user not authenticated, use DB
   419  		rep = auth.ComputeUserReputation(acct.ID)
   420  	}
   421  	postBondRes.Reputation = rep
   422  
   423  	log.Infof("Pre-paid bond accepted: acct %v from %v. Bonded tier %d, effective tier %d",
   424  		acct.ID, conn.Addr(), rep.BondedTier, rep.EffectiveTier())
   425  
   426  	resp, err := msgjson.NewResponse(msg.ID, postBondRes, nil)
   427  	if err != nil { // shouldn't be possible
   428  		return nil
   429  	}
   430  	err = conn.Send(resp)
   431  	if err != nil {
   432  		log.Warnf("Error sending pre-paid bond result to user %v: %v", acct.ID, err)
   433  		if err = auth.Send(acct.ID, resp); err != nil {
   434  			log.Warnf("Error sending pre-paid notification to account %v: %v", acct.ID, err)
   435  		}
   436  	}
   437  	return nil
   438  }
   439  
   440  // waitBondConfs is a coin waiter that should be started after validating a bond
   441  // transaction in the postbond request handler. This waits for the transaction
   442  // output referenced by coinID to reach reqConfs, and then re-validates the
   443  // amount and address to which the coinID pays. If the checks pass, the account
   444  // is marked as paid in storage by saving the coinID for the accountID. Finally,
   445  // a FeePaidNotification is sent to the provided conn. In case the notification
   446  // fails to send (e.g. connection no longer active), the user should check paid
   447  // status on 'connect'.
   448  func (auth *AuthManager) waitBondConfs(ctx context.Context, conn comms.Link, bond *db.Bond, acct *account.Account,
   449  	reqConfs int64, newAcct bool, reqID uint64, postBondRes *msgjson.PostBondResult) wait.TryDirective {
   450  	assetID, coinID := bond.AssetID, bond.CoinID
   451  	amt, _, confs, _, err := auth.checkBond(ctx, assetID, bond.Version, coinID)
   452  	if err != nil {
   453  		// This is unexpected because we already validated everything, so
   454  		// hopefully this is a transient failure such as RPC connectivity.
   455  		log.Warnf("Unexpected error checking bond coin: %v", err)
   456  		return wait.TryAgain
   457  	}
   458  	if confs < reqConfs {
   459  		return wait.TryAgain
   460  	}
   461  	acctID := acct.ID
   462  
   463  	// Verify the bond amount as a spot check. This should be redundant with the
   464  	// parseBondTx checks. If it disagrees, there is a bug in the fee asset
   465  	// backend, and the operator will need to intervene.
   466  	if amt != bond.Amount {
   467  		log.Errorf("checkFee: account %v fee coin %x pays %d; expected %d",
   468  			acctID, coinID, amt, bond.Amount)
   469  		return wait.DontTryAgain
   470  	}
   471  
   472  	// Store and respond
   473  	log.Debugf("Activating new bond %s (%s) committing %d for user %v",
   474  		coinIDString(assetID, coinID), dex.BipIDSymbol(assetID), amt, acctID)
   475  	auth.storeBondAndRespond(conn, bond, acct, newAcct, reqID, postBondRes)
   476  
   477  	return wait.DontTryAgain
   478  }
   479  
   480  // coinNotFound sends an error response for a coin not found.
   481  func (auth *AuthManager) coinNotFound(acctID account.AccountID, msgID uint64, coinID []byte) {
   482  	resp, err := msgjson.NewResponse(msgID, nil, &msgjson.Error{
   483  		Code:    msgjson.TransactionUndiscovered,
   484  		Message: fmt.Sprintf("failed to find transaction %x", coinID),
   485  	})
   486  	if err != nil {
   487  		log.Error("NewResponse error in (Swapper).loop: %v", err)
   488  	}
   489  	if err := auth.Send(acctID, resp); err != nil {
   490  		log.Infof("Failed to send coin-not-found error to user %s: %v", acctID, err)
   491  	}
   492  }