Skip to content

Commit da02895

Browse files
authored
Balances are now resistant to outbound transfer races (#199)
1 parent 0f120a8 commit da02895

File tree

19 files changed

+360
-152
lines changed

19 files changed

+360
-152
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
filippo.io/edwards25519 v1.1.0
77
github.com/aws/aws-sdk-go-v2 v0.17.0
88
github.com/bits-and-blooms/bloom/v3 v3.1.0
9-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250605155512-63da5d11d58a
9+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250610140050-4cadbcc86f16
1010
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba
1111
github.com/emirpasic/gods v1.12.0
1212
github.com/envoyproxy/protoc-gen-validate v1.2.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
8080
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
8181
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
8282
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
83-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250605155512-63da5d11d58a h1:h5AFZjmn+Zzkkd0u2Y+h9msj7HYBOSI3l4i5CD0ls34=
84-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250605155512-63da5d11d58a/go.mod h1:ee6TzKbgMS42ZJgaFEMG3c4R3dGOiffHSu6MrY7WQvs=
83+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250610140050-4cadbcc86f16 h1:drAMKRdbyObW8E4H6xc1pKIDxoFYgpaTdMlEnIKBIJ0=
84+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250610140050-4cadbcc86f16/go.mod h1:ee6TzKbgMS42ZJgaFEMG3c4R3dGOiffHSu6MrY7WQvs=
8585
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba h1:Bkp+gmeb6Y2PWXfkSCTMBGWkb2P1BujRDSjWeI+0j5I=
8686
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba/go.mod h1:jSiifpiBpyBQ8q2R0MGEbkSgWC6sbdRTyDBntmW+j1E=
8787
github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw=

pkg/code/async/account/gift_card.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
1717

18+
"github.com/code-payments/code-server/pkg/code/balance"
1819
"github.com/code-payments/code-server/pkg/code/common"
1920
code_data "github.com/code-payments/code-server/pkg/code/data"
2021
"github.com/code-payments/code-server/pkg/code/data/account"
@@ -93,6 +94,12 @@ func (p *service) maybeInitiateGiftCardAutoReturn(ctx context.Context, accountIn
9394
return err
9495
}
9596

97+
balanceLock, err := balance.GetOptimisticVersionLock(ctx, p.data, giftCardVaultAccount)
98+
if err != nil {
99+
log.WithError(err).Warn("failure getting balance lock")
100+
return err
101+
}
102+
96103
_, err = p.data.GetGiftCardClaimedAction(ctx, giftCardVaultAccount.PublicKey().ToBase58())
97104
if err == nil {
98105
log.Trace("gift card is claimed and will be removed from worker queue")
@@ -124,7 +131,7 @@ func (p *service) maybeInitiateGiftCardAutoReturn(ctx context.Context, accountIn
124131
// There's no action to claim the gift card and the expiry window has been met.
125132
// It's time to initiate the process of auto-returning the funds back to the
126133
// issuer.
127-
err = InitiateProcessToAutoReturnGiftCard(ctx, p.data, giftCardVaultAccount, false)
134+
err = InitiateProcessToAutoReturnGiftCard(ctx, p.data, giftCardVaultAccount, false, balanceLock)
128135
if err != nil {
129136
log.WithError(err).Warn("failure initiating process to return gift card balance to issuer")
130137
return err
@@ -138,7 +145,7 @@ func (p *service) maybeInitiateGiftCardAutoReturn(ctx context.Context, accountIn
138145
// a good guide for similar actions in the future.
139146
//
140147
// todo: This probably belongs somewhere more common
141-
func InitiateProcessToAutoReturnGiftCard(ctx context.Context, data code_data.Provider, giftCardVaultAccount *common.Account, isVoidedByUser bool) error {
148+
func InitiateProcessToAutoReturnGiftCard(ctx context.Context, data code_data.Provider, giftCardVaultAccount *common.Account, isVoidedByUser bool, balanceLock *balance.OptimisticVersionLock) error {
142149
return data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error {
143150
giftCardIssuedIntent, err := data.GetOriginalGiftCardIssuedIntent(ctx, giftCardVaultAccount.PublicKey().ToBase58())
144151
if err != nil {
@@ -199,7 +206,12 @@ func InitiateProcessToAutoReturnGiftCard(ctx context.Context, data code_data.Pro
199206

200207
// This will trigger the fulfillment worker to poll for the fulfillment. This
201208
// should be the very last DB update called.
202-
return markFulfillmentAsActivelyScheduled(ctx, data, autoReturnFulfillment[0])
209+
err = markFulfillmentAsActivelyScheduled(ctx, data, autoReturnFulfillment[0])
210+
if err != nil {
211+
return err
212+
}
213+
214+
return balanceLock.OnCommit(ctx, data)
203215
})
204216
}
205217

pkg/code/balance/calculator.go

Lines changed: 49 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,28 @@ type State struct {
5555
current int64
5656
}
5757

58+
// Calculate calculates a token account's balance using a starting point and a set
59+
// of strategies. Each may be incomplete individually, but in total must form a
60+
// complete balance calculation.
61+
func Calculate(ctx context.Context, tokenAccount *common.Account, initialBalance uint64, strategies ...Strategy) (balance uint64, err error) {
62+
balanceState := &State{
63+
current: int64(initialBalance),
64+
}
65+
66+
for _, strategy := range strategies {
67+
balanceState, err = strategy(ctx, tokenAccount, balanceState)
68+
if err != nil {
69+
return 0, err
70+
}
71+
}
72+
73+
if balanceState.current < 0 {
74+
return 0, ErrNegativeBalance
75+
}
76+
77+
return uint64(balanceState.current), nil
78+
}
79+
5880
// CalculateFromCache is the default and recommended strategy for reliably estimating
5981
// a token account's balance using cached values.
6082
//
@@ -168,28 +190,6 @@ func CalculateFromBlockchain(ctx context.Context, data code_data.Provider, token
168190
return quarks, BlockchainSource, nil
169191
}
170192

171-
// Calculate calculates a token account's balance using a starting point and a set
172-
// of strategies. Each may be incomplete individually, but in total must form a
173-
// complete balance calculation.
174-
func Calculate(ctx context.Context, tokenAccount *common.Account, initialBalance uint64, strategies ...Strategy) (balance uint64, err error) {
175-
balanceState := &State{
176-
current: int64(initialBalance),
177-
}
178-
179-
for _, strategy := range strategies {
180-
balanceState, err = strategy(ctx, tokenAccount, balanceState)
181-
if err != nil {
182-
return 0, err
183-
}
184-
}
185-
186-
if balanceState.current < 0 {
187-
return 0, ErrNegativeBalance
188-
}
189-
190-
return uint64(balanceState.current), nil
191-
}
192-
193193
// NetBalanceFromIntentActions is a balance calculation strategy that incorporates
194194
// the net balance by applying payment intents to the current balance.
195195
func NetBalanceFromIntentActions(ctx context.Context, data code_data.Provider) Strategy {
@@ -243,14 +243,39 @@ type BatchState struct {
243243
current map[string]int64
244244
}
245245

246+
// CalculateBatch calculates a set of token accounts' balance using a starting point
247+
// and a set of strategies. Each may be incomplete individually, but in total must
248+
// form a complete balance calculation.
249+
func CalculateBatch(ctx context.Context, tokenAccounts []string, strategies ...BatchStrategy) (balanceByTokenAccount map[string]uint64, err error) {
250+
balanceState := &BatchState{
251+
current: make(map[string]int64),
252+
}
253+
254+
for _, strategy := range strategies {
255+
balanceState, err = strategy(ctx, tokenAccounts, balanceState)
256+
if err != nil {
257+
return nil, err
258+
}
259+
}
260+
261+
res := make(map[string]uint64)
262+
for tokenAccount, balance := range balanceState.current {
263+
if balance < 0 {
264+
return nil, ErrNegativeBalance
265+
}
266+
267+
res[tokenAccount] = uint64(balance)
268+
}
269+
270+
return res, nil
271+
}
272+
246273
// BatchCalculateFromCacheWithAccountRecords is the default and recommended batch strategy
247274
// or reliably estimating a set of token accounts' balance when common.AccountRecords are
248275
// available.
249276
//
250277
// Note: Use this method when calculating balances for accounts that are managed by
251278
// Code (ie. Timelock account) and operate within the L2 system.
252-
//
253-
// Note: This only supports post-privacy accounts. Use CalculateFromCache instead.
254279
func BatchCalculateFromCacheWithAccountRecords(ctx context.Context, data code_data.Provider, accountRecordsBatch ...*common.AccountRecords) (map[string]uint64, error) {
255280
tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "BatchCalculateFromCacheWithAccountRecords")
256281
defer tracer.End()
@@ -279,8 +304,6 @@ func BatchCalculateFromCacheWithAccountRecords(ctx context.Context, data code_da
279304
//
280305
// Note: Use this method when calculating balances for accounts that are managed by
281306
// Code (ie. Timelock account) and operate within the L2 system.
282-
//
283-
// Note: This only supports post-privacy accounts. Use CalculateFromCache instead.
284307
func BatchCalculateFromCacheWithTokenAccounts(ctx context.Context, data code_data.Provider, tokenAccounts ...*common.Account) (map[string]uint64, error) {
285308
tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "BatchCalculateFromCacheWithTokenAccounts")
286309
defer tracer.End()
@@ -333,33 +356,6 @@ func defaultBatchCalculationFromCache(ctx context.Context, data code_data.Provid
333356
)
334357
}
335358

336-
// CalculateBatch calculates a set of token accounts' balance using a starting point
337-
// and a set of strategies. Each may be incomplete individually, but in total must
338-
// form a complete balance calculation.
339-
func CalculateBatch(ctx context.Context, tokenAccounts []string, strategies ...BatchStrategy) (balanceByTokenAccount map[string]uint64, err error) {
340-
balanceState := &BatchState{
341-
current: make(map[string]int64),
342-
}
343-
344-
for _, strategy := range strategies {
345-
balanceState, err = strategy(ctx, tokenAccounts, balanceState)
346-
if err != nil {
347-
return nil, err
348-
}
349-
}
350-
351-
res := make(map[string]uint64)
352-
for tokenAccount, balance := range balanceState.current {
353-
if balance < 0 {
354-
return nil, ErrNegativeBalance
355-
}
356-
357-
res[tokenAccount] = uint64(balance)
358-
}
359-
360-
return res, nil
361-
}
362-
363359
// NetBalanceFromIntentActionsBatch is a balance calculation strategy that incorporates
364360
// the net balance by applying payment intents to the current balance.
365361
func NetBalanceFromIntentActionsBatch(ctx context.Context, data code_data.Provider) BatchStrategy {

pkg/code/balance/lock.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package balance
2+
3+
import (
4+
"context"
5+
6+
"github.com/code-payments/code-server/pkg/code/common"
7+
code_data "github.com/code-payments/code-server/pkg/code/data"
8+
)
9+
10+
// OptimisticVersionLock is an optimistic version lock on an account's cached
11+
// balance, which can be paired with DB updates against balances that need to
12+
// be protected against race conditions.
13+
type OptimisticVersionLock struct {
14+
vault *common.Account
15+
currentVersion uint64
16+
}
17+
18+
// GetOptimisticVersionLock gets an optimistic version lock for the vault account's
19+
// cached balance
20+
func GetOptimisticVersionLock(ctx context.Context, data code_data.Provider, vault *common.Account) (*OptimisticVersionLock, error) {
21+
version, err := data.GetCachedBalanceVersion(ctx, vault.PublicKey().ToBase58())
22+
if err != nil {
23+
return nil, err
24+
}
25+
return &OptimisticVersionLock{
26+
vault: vault,
27+
currentVersion: version,
28+
}, nil
29+
}
30+
31+
// OnCommit is called in the DB transaction updating the account's cached balance
32+
func (l *OptimisticVersionLock) OnCommit(ctx context.Context, data code_data.Provider) error {
33+
return data.AdvanceCachedBalanceVersion(ctx, l.vault.PublicKey().ToBase58(), l.currentVersion)
34+
}

pkg/code/data/balance/memory/store.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,54 @@ import (
99
)
1010

1111
type store struct {
12-
mu sync.Mutex
13-
externalCheckpointRecords []*balance.ExternalCheckpointRecord
14-
last uint64
12+
mu sync.Mutex
13+
cachedBalanceVersionsByAccount map[string]uint64
14+
externalCheckpointRecords []*balance.ExternalCheckpointRecord
15+
last uint64
1516
}
1617

1718
// New returns a new in memory balance.Store
1819
func New() balance.Store {
19-
return &store{}
20+
return &store{
21+
cachedBalanceVersionsByAccount: make(map[string]uint64),
22+
}
23+
}
24+
25+
// GetCachedVersion implements balance.Store.GetCachedVersion
26+
func (s *store) GetCachedVersion(_ context.Context, account string) (uint64, error) {
27+
s.mu.Lock()
28+
defer s.mu.Unlock()
29+
30+
current, ok := s.cachedBalanceVersionsByAccount[account]
31+
if !ok {
32+
return 0, nil
33+
}
34+
return current, nil
35+
}
36+
37+
// AdvanceCachedVersion implements balance.Store.AdvanceCachedVersion
38+
func (s *store) AdvanceCachedVersion(_ context.Context, account string, currentVersion uint64) error {
39+
s.mu.Lock()
40+
defer s.mu.Unlock()
41+
42+
actualVersion, ok := s.cachedBalanceVersionsByAccount[account]
43+
if !ok {
44+
if currentVersion != 0 {
45+
return balance.ErrStaleCachedBalanceVersion
46+
}
47+
48+
s.cachedBalanceVersionsByAccount[account] = 1
49+
50+
return nil
51+
}
52+
53+
if actualVersion != currentVersion {
54+
return balance.ErrStaleCachedBalanceVersion
55+
}
56+
57+
s.cachedBalanceVersionsByAccount[account]++
58+
59+
return nil
2060
}
2161

2262
// SaveExternalCheckpoint implements balance.Store.SaveExternalCheckpoint
@@ -87,6 +127,7 @@ func (s *store) reset() {
87127
s.mu.Lock()
88128
defer s.mu.Unlock()
89129

130+
s.cachedBalanceVersionsByAccount = make(map[string]uint64)
90131
s.externalCheckpointRecords = nil
91132
s.last = 0
92133
}

pkg/code/data/balance/postgres/model.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import (
88
"github.com/jmoiron/sqlx"
99

1010
"github.com/code-payments/code-server/pkg/code/data/balance"
11+
pg "github.com/code-payments/code-server/pkg/database/postgres"
1112
pgutil "github.com/code-payments/code-server/pkg/database/postgres"
1213
)
1314

1415
const (
15-
externalCheckpointTableName = "codewallet__core_externalbalancecheckpoint"
16+
cachedBalanceVersionTableName = "codewallet__core_cachedbalanceversion"
17+
externalCheckpointTableName = "codewallet__core_externalbalancecheckpoint"
1618
)
1719

1820
type externalCheckpointModel struct {
@@ -25,6 +27,49 @@ type externalCheckpointModel struct {
2527
LastUpdatedAt time.Time `db:"last_updated_at"`
2628
}
2729

30+
func dbGetCachedVersion(ctx context.Context, db *sqlx.DB, account string) (uint64, error) {
31+
var res uint64
32+
query := `SELECT version FROM ` + cachedBalanceVersionTableName + `
33+
WHERE token_account = $1`
34+
err := db.GetContext(ctx, &res, query, account)
35+
if pg.IsNoRows(err) {
36+
return 0, nil
37+
} else if err != nil {
38+
return 0, err
39+
}
40+
return res, nil
41+
}
42+
43+
func dbAdvanceCachedVersion(ctx context.Context, db *sqlx.DB, account string, currentVersion uint64) error {
44+
return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error {
45+
query := `INSERT INTO ` + cachedBalanceVersionTableName + `
46+
(token_account, version)
47+
VALUES ($1, 1)
48+
RETURNING version
49+
`
50+
params := []any{account}
51+
if currentVersion > 0 {
52+
query = `UPDATE ` + cachedBalanceVersionTableName + `
53+
SET version = version + 1
54+
WHERE token_account = $1 AND version = $2
55+
RETURNING version
56+
`
57+
params = append(params, currentVersion)
58+
}
59+
60+
var res uint64
61+
err := tx.GetContext(ctx, &res, query, params...)
62+
if pg.IsNoRows(err) || pg.IsUniqueViolation(err) {
63+
return balance.ErrStaleCachedBalanceVersion
64+
}
65+
if err != nil {
66+
return err
67+
}
68+
return nil
69+
})
70+
71+
}
72+
2873
func toExternalCheckpointModel(obj *balance.ExternalCheckpointRecord) (*externalCheckpointModel, error) {
2974
if err := obj.Validate(); err != nil {
3075
return nil, err
@@ -80,9 +125,7 @@ func (m *externalCheckpointModel) dbSave(ctx context.Context, db *sqlx.DB) error
80125
func dbGetExternalCheckpoint(ctx context.Context, db *sqlx.DB, account string) (*externalCheckpointModel, error) {
81126
res := &externalCheckpointModel{}
82127

83-
query := `SELECT
84-
id, token_account, quarks, slot_checkpoint, last_updated_at
85-
FROM ` + externalCheckpointTableName + `
128+
query := `SELECT id, token_account, quarks, slot_checkpoint, last_updated_at FROM ` + externalCheckpointTableName + `
86129
WHERE token_account = $1
87130
LIMIT 1`
88131

0 commit comments

Comments
 (0)