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 }