-
Notifications
You must be signed in to change notification settings - Fork 86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
server/auth: scoring for offline users must load history from DB #1083
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -657,44 +657,73 @@ func (auth *AuthManager) UserSettlingLimit(user account.AccountID, mkt *dex.Mark | |
return limit | ||
} | ||
|
||
// userScore computes the user score from the user's recent match outcomes and | ||
// preimage history. This must be called with the violationMtx locked. | ||
func (auth *AuthManager) userScore(user account.AccountID) (score int32) { | ||
if outcomes, found := auth.matchOutcomes[user]; found { | ||
for v, count := range outcomes.binViolations() { | ||
func integrateOutcomes(matchOutcomes *latestMatchOutcomes, preimgOutcomes *latestPreimageOutcomes) (score, successCount, piMissCount int32) { | ||
if matchOutcomes != nil { | ||
matchCounts := matchOutcomes.binViolations() | ||
for v, count := range matchCounts { | ||
score += v.Score() * int32(count) | ||
} | ||
successCount = int32(matchCounts[ViolationSwapSuccess]) | ||
} | ||
if outcomes, found := auth.preimgOutcomes[user]; found { | ||
score += ViolationPreimageMiss.Score() * outcomes.misses() | ||
if preimgOutcomes != nil { | ||
piMissCount = preimgOutcomes.misses() | ||
score += ViolationPreimageMiss.Score() * piMissCount | ||
} | ||
return | ||
} | ||
|
||
func (auth *AuthManager) registerMatchOutcome(user account.AccountID, misstep NoActionStep, mmid db.MarketMatchID, value uint64, refTime time.Time) int32 { | ||
// 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. | ||
func (auth *AuthManager) userScore(user account.AccountID) (score int32) { | ||
score, _, _ = integrateOutcomes(auth.matchOutcomes[user], auth.preimgOutcomes[user]) | ||
return score | ||
} | ||
|
||
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() | ||
outcomes, found := auth.matchOutcomes[user] | ||
if !found { | ||
outcomes = newLatestMatchOutcomes(scoringMatchLimit) | ||
auth.matchOutcomes[user] = outcomes | ||
} | ||
outcomes.add(&matchOutcome{ | ||
time: encode.UnixMilli(refTime), | ||
mid: mmid.MatchID, | ||
outcome: violation, | ||
value: value, | ||
base: mmid.Base, | ||
quote: mmid.Quote, | ||
}) | ||
matchOutcomes, found := auth.matchOutcomes[user] | ||
if found { | ||
matchOutcomes.add(&matchOutcome{ | ||
time: encode.UnixMilli(refTime), | ||
mid: mmid.MatchID, | ||
outcome: violation, | ||
value: value, | ||
base: mmid.Base, | ||
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) | ||
return | ||
} | ||
|
||
// 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) | ||
if err != nil { | ||
log.Errorf("Failed to load swap and preimage 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 | ||
Comment on lines
+715
to
+720
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would be safe with the new duplicate checks in the outcome structs' Even for the largest matches table so far, the |
||
|
||
score := auth.userScore(user) | ||
log.Debugf("Registering outcome %q (badness %d) for user %v, new score = %d", | ||
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 score | ||
return | ||
} | ||
|
||
// SwapSuccess registers the successful completion of a swap by the given user. | ||
|
@@ -728,26 +757,48 @@ func (auth *AuthManager) Inaction(user account.AccountID, misstep NoActionStep, | |
} | ||
} | ||
|
||
func (auth *AuthManager) registerPreimageOutcome(user account.AccountID, miss bool, oid order.OrderID, refTime time.Time) int32 { | ||
func (auth *AuthManager) registerPreimageOutcome(user account.AccountID, miss bool, oid order.OrderID, refTime time.Time) (score int32) { | ||
auth.violationMtx.Lock() | ||
defer auth.violationMtx.Unlock() | ||
outcomes, found := auth.preimgOutcomes[user] | ||
if !found { | ||
outcomes = newLatestPreimageOutcomes(scoringOrderLimit) | ||
auth.preimgOutcomes[user] = outcomes | ||
piOutcomes, found := auth.preimgOutcomes[user] | ||
if found { | ||
piOutcomes.add(&preimageOutcome{ | ||
time: encode.UnixMilli(refTime), | ||
oid: oid, | ||
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) | ||
} | ||
return | ||
} | ||
outcomes.add(&preimageOutcome{ | ||
time: encode.UnixMilli(refTime), | ||
oid: oid, | ||
miss: miss, | ||
}) | ||
|
||
score := auth.userScore(user) | ||
// 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) | ||
if err != nil { | ||
log.Errorf("Failed to load swap and preimage 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, new score = %d", | ||
log.Debugf("Registering outcome %q (badness %d) for user %v (offline), current score = %d", | ||
ViolationPreimageMiss.String(), ViolationPreimageMiss.Score(), user, score) | ||
} | ||
return score | ||
|
||
return | ||
} | ||
|
||
// PreimageSuccess registers an accepted preimage for the user. | ||
|
@@ -944,15 +995,14 @@ func (auth *AuthManager) removeClient(client *clientInfo) { | |
auth.violationMtx.Unlock() | ||
} | ||
|
||
// loadUserScore computes the user's current score from order and swap data | ||
// retrieved from the DB. | ||
func (auth *AuthManager) loadUserScore(user account.AccountID) (int32, error) { | ||
var successCount, piMissCount int32 | ||
// 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) { | ||
// 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 0, fmt.Errorf("CompletedAndAtFaultMatchStats: %w", err) | ||
return nil, nil, fmt.Errorf("CompletedAndAtFaultMatchStats: %w", err) | ||
} | ||
|
||
matchStatusToViol := func(status order.MatchStatus) Violation { | ||
|
@@ -975,22 +1025,16 @@ func (auth *AuthManager) loadUserScore(user account.AccountID) (int32, error) { | |
// Load the count of preimage misses in the N most recently placed orders. | ||
piOutcomes, err := auth.storage.PreimageStats(user, scoringOrderLimit) | ||
if err != nil { | ||
return 0, fmt.Errorf("PreimageStats: %w", err) | ||
return nil, nil, fmt.Errorf("PreimageStats: %w", err) | ||
} | ||
|
||
auth.violationMtx.Lock() | ||
defer auth.violationMtx.Unlock() | ||
|
||
latestMatches := newLatestMatchOutcomes(scoringMatchLimit) | ||
auth.matchOutcomes[user] = latestMatches | ||
for _, mo := range matchOutcomes { | ||
// The Fail flag qualifies MakerRedeemed, which is always success for | ||
// maker, but fail for taker if revoked. | ||
v := ViolationSwapSuccess | ||
if mo.Fail { | ||
v = matchStatusToViol(mo.Status) | ||
} else { | ||
successCount++ | ||
} | ||
latestMatches.add(&matchOutcome{ | ||
time: mo.Time, | ||
|
@@ -1003,20 +1047,35 @@ func (auth *AuthManager) loadUserScore(user account.AccountID) (int32, error) { | |
} | ||
|
||
latestPreimageResults := newLatestPreimageOutcomes(scoringOrderLimit) | ||
auth.preimgOutcomes[user] = latestPreimageResults | ||
for _, po := range piOutcomes { | ||
if po.Miss { | ||
piMissCount++ | ||
} | ||
latestPreimageResults.add(&preimageOutcome{ | ||
time: po.Time, | ||
oid: po.ID, | ||
miss: po.Miss, | ||
}) | ||
} | ||
|
||
// Integrate the match and preimage outcomes. | ||
score := auth.userScore(user) | ||
return latestMatches, latestPreimageResults, 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. | ||
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) | ||
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 | ||
|
@@ -1131,6 +1190,16 @@ func (auth *AuthManager) handleConnect(conn comms.Link, msg *msgjson.Message) *m | |
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) | ||
} | ||
} | ||
|
||
// Get the list of active orders for this user. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1707,8 +1707,6 @@ func (m *Market) prepEpoch(orders []order.Order, epochEnd time.Time) (cSum []byt | |
for _, ord := range misses { | ||
log.Infof("No preimage received for order %v from user %v. Recording violation and revoking order.", | ||
ord.ID(), ord.User()) | ||
// Register the preimage miss violation, adjusting the user's score. | ||
m.auth.MissedPreimage(ord.User(), epochEnd, ord.ID()) | ||
// Unlock the order's coins locked in processOrder. | ||
m.unlockOrderCoins(ord) // could also be done in processReadyEpoch | ||
// Change the order status from orderStatusEpoch to orderStatusRevoked. | ||
|
@@ -1719,6 +1717,8 @@ func (m *Market) prepEpoch(orders []order.Order, epochEnd time.Time) (cSum []byt | |
log.Errorf("Failed to revoke order %v with a new cancel order: %v", | ||
ord.UID(), err) | ||
} | ||
// Register the preimage miss violation, adjusting the user's score. | ||
m.auth.MissedPreimage(ord.User(), epochEnd, ord.ID()) | ||
Comment on lines
+1720
to
+1721
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved below the DB storage of the preimage miss. |
||
} | ||
|
||
// Register the preimage collection successes, potentially evicting preimage | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the gist of the bug. I suspect it was written (by me) with the thinking "no known outcomes, so this must be a new user with no previous outcomes" but the absence of an entry really means offline user.