diff --git a/ocp/balance/calculator.go b/ocp/balance/calculator.go index 73343cc..3f515b4 100644 --- a/ocp/balance/calculator.go +++ b/ocp/balance/calculator.go @@ -2,19 +2,14 @@ package balance import ( "context" - "math" "time" "github.com/pkg/errors" - commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" - "github.com/code-payments/ocp-server/metrics" "github.com/code-payments/ocp-server/ocp/common" ocp_data "github.com/code-payments/ocp-server/ocp/data" - "github.com/code-payments/ocp-server/ocp/data/account" "github.com/code-payments/ocp-server/ocp/data/balance" - "github.com/code-payments/ocp-server/ocp/data/timelock" "github.com/code-payments/ocp-server/solana" ) @@ -31,58 +26,20 @@ const ( ) var ( - // ErrNegativeBalance indicates that a balance calculation resulted in a - // negative value. - ErrNegativeBalance = errors.New("balance calculation resulted in negative value") - // ErrNotManagedByCode indicates that an account is not owned by Code. // It's up to callers to determine how to handle this situation within // the context of a balance. ErrNotManagedByCode = errors.New("explicitly not handling account not managed by code") - - // ErrUnhandledAccount indicates that the balance calculator does not - // have strategies to handle the provided account. - ErrUnhandledAccount = errors.New("unhandled account") ) -// Calculator is a function that calculates a token account's balance -type Calculator func(ctx context.Context, data ocp_data.Provider, tokenAccount *common.Account) (uint64, error) - -type Strategy func(ctx context.Context, tokenAccount *common.Account, state *State) (*State, error) - -type State struct { - // We allow for negative balances in intermediary steps. This is to simplify - // coordination between strategies. In the end, the sum of all strategies must - // reflect an accurate picture of the balance, at which point we'll enforce this - // is positive. - current int64 -} - -// Calculate calculates a token account's balance using a starting point and a set -// of strategies. Each may be incomplete individually, but in total must form a -// complete balance calculation. -func Calculate(ctx context.Context, tokenAccount *common.Account, initialBalance uint64, strategies ...Strategy) (balance uint64, err error) { - balanceState := &State{ - current: int64(initialBalance), - } - - for _, strategy := range strategies { - balanceState, err = strategy(ctx, tokenAccount, balanceState) - if err != nil { - return 0, err - } - } - - if balanceState.current < 0 { - return 0, ErrNegativeBalance - } - - return uint64(balanceState.current), nil -} - // CalculateFromCache is the default and recommended strategy for reliably estimating // a token account's balance using cached values. // +// The ledger record is the whole answer: it exists for exactly the accounts +// Code manages, and carries the lock state that says whether it still does. +// An account with no record, or one whose vault has unlocked, is +// ErrNotManagedByCode. +// // Note: Use this method when calculating balances for accounts that are managed by // Code (ie. Timelock account) and operate within the L2 system. func CalculateFromCache(ctx context.Context, data ocp_data.Provider, tokenAccount *common.Account) (uint64, error) { @@ -90,8 +47,8 @@ func CalculateFromCache(ctx context.Context, data ocp_data.Provider, tokenAccoun tracer.AddAttribute("account", tokenAccount.PublicKey().ToBase58()) defer tracer.End() - timelockRecord, err := data.GetTimelockByVault(ctx, tokenAccount.PublicKey().ToBase58()) - if err == timelock.ErrTimelockNotFound { + balanceRecord, err := data.GetBalance(ctx, tokenAccount.PublicKey().ToBase58()) + if err == balance.ErrRecordNotFound { tracer.OnError(ErrNotManagedByCode) return 0, ErrNotManagedByCode } else if err != nil { @@ -99,48 +56,13 @@ func CalculateFromCache(ctx context.Context, data ocp_data.Provider, tokenAccoun return 0, err } - // The strategy uses cached values from the intents system. The account must - // be managed by Code in order to return accurate values. - isManagedByCode := common.IsManagedByCode(ctx, timelockRecord) - if !isManagedByCode { + // Once a vault unlocks, funds can move on chain without an intent, so the + // record stops being maintained and its balance must not be trusted. + if !balanceRecord.IsLocked { tracer.OnError(ErrNotManagedByCode) return 0, ErrNotManagedByCode } - - // Prefer the materialized balance record, when the account has one that - // reflects its full history. - if enableLedgerReads.Get(ctx) { - balanceRecord, err := data.GetBalance(ctx, tokenAccount.PublicKey().ToBase58()) - if err == nil && balanceRecord.IsBackfilled { - quarks, err := quarksFromRecord(balanceRecord) - if err != nil { - tracer.OnError(err) - return 0, err - } - return quarks, nil - } else if err != nil && err != balance.ErrRecordNotFound { - tracer.OnError(err) - return 0, err - } - } - - // Otherwise, fall back to iterating over the account's history. - strategies := []Strategy{ - NetBalanceFromIntentActions(ctx, data), - FundingFromExternalDeposits(ctx, data), - } - - balance, err := Calculate( - ctx, - tokenAccount, - 0, - strategies..., - ) - if err != nil { - tracer.OnError(err) - return 0, errors.Wrap(err, "error calculating token account balance") - } - return balance, nil + return balanceRecord.Quarks, nil } // CalculateFromBlockchain is the default and recommended strategy for reliably @@ -203,110 +125,28 @@ func CalculateFromBlockchain(ctx context.Context, data ocp_data.Provider, tokenA return quarks, BlockchainSource, nil } -// NetBalanceFromIntentActions is a balance calculation strategy that incorporates -// the net balance by applying payment intents to the current balance. -func NetBalanceFromIntentActions(ctx context.Context, data ocp_data.Provider) Strategy { - return func(ctx context.Context, tokenAccount *common.Account, state *State) (*State, error) { - netBalance, err := data.GetNetBalanceFromActions(ctx, tokenAccount.PublicKey().ToBase58()) - if err != nil { - return nil, errors.Wrap(err, "error getting net balance from intent actions") - } - - state.current += netBalance - return state, nil - } -} - -// FundingFromExternalDeposits is a balance calculation strategy that adds funding -// from deposits from external accounts. -func FundingFromExternalDeposits(ctx context.Context, data ocp_data.Provider) Strategy { - return func(ctx context.Context, tokenAccount *common.Account, state *State) (*State, error) { - amount, err := data.GetTotalExternalDepositedAmountInQuarks(ctx, tokenAccount.PublicKey().ToBase58()) - if err != nil { - return nil, errors.Wrap(err, "error getting external deposit amount") - } - state.current += int64(amount) - - return state, nil - } -} - -// BatchCalculator is a functiona that calculates a batch of accounts' balances -type BatchCalculator func(ctx context.Context, data ocp_data.Provider, accountRecordsBatch []*common.AccountRecords) (map[string]uint64, error) - -type BatchStrategy func(ctx context.Context, tokenAccounts []string, state *BatchState) (*BatchState, error) - -type BatchState struct { - // We allow for negative balances in intermediary steps. This is to simplify - // coordination between strategies. In the end, the sum of all strategies must - // reflect an accurate picture of the balance, at which point we'll enforce this - // is positive. - current map[string]int64 -} - -// CalculateBatch calculates a set of token accounts' balance using a starting point -// and a set of strategies. Each may be incomplete individually, but in total must -// form a complete balance calculation. -func CalculateBatch(ctx context.Context, tokenAccounts []string, strategies ...BatchStrategy) (balanceByTokenAccount map[string]uint64, err error) { - balanceState := &BatchState{ - current: make(map[string]int64), - } - - for _, strategy := range strategies { - balanceState, err = strategy(ctx, tokenAccounts, balanceState) - if err != nil { - return nil, err - } - } - - res := make(map[string]uint64) - for tokenAccount, balance := range balanceState.current { - if balance < 0 { - return nil, ErrNegativeBalance - } - - res[tokenAccount] = uint64(balance) - } - - return res, nil +// Balance is a token account's quark balance and USD cost basis, in +// balance.UsdQuarksPerUnit, alongside the mint it holds. +type Balance struct { + MintAccount string + Quarks uint64 + UsdCostBasis int64 } -// BatchCalculateFromCacheWithAccountRecords is the default and recommended batch strategy -// or reliably estimating a set of token accounts' balance when common.AccountRecords are -// available. +// BatchCalculateFromCache is the default and recommended batch strategy for +// reliably estimating a set of token accounts' balances using cached values. +// Both values for an account come from the same ledger record read, so they +// are guaranteed consistent with each other. // -// Note: Use this method when calculating balances for accounts that are managed by -// Code (ie. Timelock account) and operate within the L2 system. -func BatchCalculateFromCacheWithAccountRecords(ctx context.Context, data ocp_data.Provider, accountRecordsBatch ...*common.AccountRecords) (map[string]uint64, error) { - tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "BatchCalculateFromCacheWithAccountRecords") - defer tracer.End() - - timelockRecords := make([]*timelock.Record, 0) - for _, accountRecords := range accountRecordsBatch { - if !accountRecords.IsTimelock() { - tracer.OnError(ErrNotManagedByCode) - return nil, ErrNotManagedByCode - } - - timelockRecords = append(timelockRecords, accountRecords.Timelock) - } - - balanceByTokenAccount, err := defaultBatchCalculationFromCache(ctx, data, timelockRecords) - if err != nil { - tracer.OnError(err) - return nil, err - } - return balanceByTokenAccount, nil -} - -// BatchCalculateFromCacheWithTokenAccounts is the default and recommended batch strategy -// or reliably estimating a set of token accounts' balance when common.Account are -// available. +// Accounts the ledger doesn't manage are omitted from the result: those with +// no record, and those whose vault has unlocked. A caller that requires every +// account it asked for to be managed compares the result's length against its +// input. // // Note: Use this method when calculating balances for accounts that are managed by // Code (ie. Timelock account) and operate within the L2 system. -func BatchCalculateFromCacheWithTokenAccounts(ctx context.Context, data ocp_data.Provider, tokenAccounts ...*common.Account) (map[string]uint64, error) { - tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "BatchCalculateFromCacheWithTokenAccounts") +func BatchCalculateFromCache(ctx context.Context, data ocp_data.Provider, tokenAccounts ...*common.Account) (map[string]*Balance, error) { + tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "BatchCalculateFromCache") defer tracer.End() tokenAccountStrings := make([]string, len(tokenAccounts)) @@ -314,320 +154,67 @@ func BatchCalculateFromCacheWithTokenAccounts(ctx context.Context, data ocp_data tokenAccountStrings[i] = tokenAccount.PublicKey().ToBase58() } - timelockRecordsByVault, err := data.GetTimelockByVaultBatch(ctx, tokenAccountStrings...) - if err == timelock.ErrTimelockNotFound { - tracer.OnError(ErrNotManagedByCode) - return nil, ErrNotManagedByCode - } else if err != nil { - tracer.OnError(err) - return nil, err - } - - timelockRecords := make([]*timelock.Record, 0, len(timelockRecordsByVault)) - for _, timelockRecord := range timelockRecordsByVault { - timelockRecords = append(timelockRecords, timelockRecord) - } - - balanceByTokenAccount, err := defaultBatchCalculationFromCache(ctx, data, timelockRecords) + balanceRecords, err := data.GetBalanceBatch(ctx, tokenAccountStrings...) if err != nil { tracer.OnError(err) return nil, err } - return balanceByTokenAccount, nil -} - -func defaultBatchCalculationFromCache(ctx context.Context, data ocp_data.Provider, timelockRecords []*timelock.Record) (map[string]uint64, error) { - var tokenAccounts []string - for _, timelockRecord := range timelockRecords { - // The strategy uses cached values from the intents system. The account must - // be managed by Code in order to return accurate values. - isManagedByCode := common.IsManagedByCode(ctx, timelockRecord) - if !isManagedByCode { - return nil, ErrNotManagedByCode - } - - tokenAccounts = append(tokenAccounts, timelockRecord.VaultAddress) - } - - // Prefer materialized balance records, and only iterate over history for - // accounts that don't yet have a fully backfilled one. - balanceRecords := make(map[string]*balance.Record) - if enableLedgerReads.Get(ctx) { - var err error - balanceRecords, err = data.GetBalanceBatch(ctx, tokenAccounts...) - if err != nil { - return nil, err - } - } - - res := make(map[string]uint64, len(tokenAccounts)) - var remaining []string - for _, tokenAccount := range tokenAccounts { - balanceRecord, ok := balanceRecords[tokenAccount] - if !ok || !balanceRecord.IsBackfilled { - remaining = append(remaining, tokenAccount) - continue - } - quarks, err := quarksFromRecord(balanceRecord) - if err != nil { - return nil, err + res := make(map[string]*Balance, len(balanceRecords)) + for tokenAccount, balanceRecord := range balanceRecords { + if cached, ok := balanceFromRecord(balanceRecord); ok { + res[tokenAccount] = cached } - res[tokenAccount] = quarks - } - - if len(remaining) == 0 { - return res, nil - } - - legacyRes, err := CalculateBatch( - ctx, - remaining, - NetBalanceFromIntentActionsBatch(ctx, data), - FundingFromExternalDepositsBatch(ctx, data), - ) - if err != nil { - return nil, err - } - for tokenAccount, quarks := range legacyRes { - res[tokenAccount] = quarks } return res, nil } -// BalanceWithUsdCostBasis holds a token account's quark balance and USD cost -// basis, in balance.UsdQuarksPerUnit. -type BalanceWithUsdCostBasis struct { - Quarks uint64 - UsdCostBasis int64 -} - -// BatchCalculateWithUsdCostBasisFromCache calculates balances and USD cost -// bases for a set of account records. When ledger reads are enabled, both -// values for an account come from the same balance record read, so they are -// guaranteed consistent with each other. Accounts without a backfilled -// record fall back to the legacy aggregates, which read the two values from -// separate sources. +// BatchCalculateFromCacheByOwner is the default and recommended strategy for +// reliably estimating the balance of every account an owner holds, keyed by +// token account. Each balance carries the mint its account holds, so a caller +// aggregating across mints doesn't need the account records to say which is +// which. +// +// Accounts the ledger doesn't manage are omitted, as are owners it holds +// nothing for, so an owner outside the L2 system is an empty result rather +// than an error. // // Note: Use this method when calculating balances for accounts that are managed by // Code (ie. Timelock account) and operate within the L2 system. -func BatchCalculateWithUsdCostBasisFromCache(ctx context.Context, data ocp_data.Provider, accountRecordsBatch ...*common.AccountRecords) (map[string]*BalanceWithUsdCostBasis, error) { - tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "BatchCalculateWithUsdCostBasisFromCache") +func BatchCalculateFromCacheByOwner(ctx context.Context, data ocp_data.Provider, owner *common.Account) (map[string]*Balance, error) { + tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "BatchCalculateFromCacheByOwner") + tracer.AddAttribute("owner", owner.PublicKey().ToBase58()) defer tracer.End() - var tokenAccounts []string - for _, accountRecords := range accountRecordsBatch { - if !accountRecords.IsTimelock() || !accountRecords.IsManagedByCode(ctx) { - tracer.OnError(ErrNotManagedByCode) - return nil, ErrNotManagedByCode - } - tokenAccounts = append(tokenAccounts, accountRecords.General.TokenAccount) - } - - balanceRecords := make(map[string]*balance.Record) - if enableLedgerReads.Get(ctx) { - var err error - balanceRecords, err = data.GetBalanceBatch(ctx, tokenAccounts...) - if err != nil { - tracer.OnError(err) - return nil, err - } - } - - res := make(map[string]*BalanceWithUsdCostBasis, len(tokenAccounts)) - var remaining []string - for _, tokenAccount := range tokenAccounts { - balanceRecord, ok := balanceRecords[tokenAccount] - if !ok || !balanceRecord.IsBackfilled { - remaining = append(remaining, tokenAccount) - continue - } - - quarks, err := quarksFromRecord(balanceRecord) - if err != nil { - tracer.OnError(err) - return nil, err - } - res[tokenAccount] = &BalanceWithUsdCostBasis{ - Quarks: quarks, - UsdCostBasis: balanceRecord.UsdCostBasis, - } - } - - if len(remaining) == 0 { - return res, nil - } - - legacyQuarks, err := CalculateBatch( - ctx, - remaining, - NetBalanceFromIntentActionsBatch(ctx, data), - FundingFromExternalDepositsBatch(ctx, data), - ) - if err != nil { + balanceRecords, err := data.GetAllBalancesByOwner(ctx, owner.PublicKey().ToBase58()) + if err != nil && err != balance.ErrRecordNotFound { tracer.OnError(err) return nil, err } - for _, tokenAccount := range remaining { - usdCostBasis, err := legacyUsdCostBasis(ctx, data, tokenAccount) - if err != nil { - tracer.OnError(err) - return nil, err - } - res[tokenAccount] = &BalanceWithUsdCostBasis{ - Quarks: legacyQuarks[tokenAccount], - UsdCostBasis: usdCostBasis, - } - } - return res, nil -} - -// CalculateUsdCostBasisFromCache calculates a token account's USD cost basis, -// in balance.UsdQuarksPerUnit, using cached values. -// -// Note: Unlike quark balances, a cost basis for an account not managed by Code -// is still meaningful, so no timelock check is performed and such accounts fall -// back to the legacy calculation. The materialized record is the exception: -// once a vault unlocks it holds the last managed state rather than a live cost -// basis, so reading one returns ErrNotManagedByCode. -func CalculateUsdCostBasisFromCache(ctx context.Context, data ocp_data.Provider, tokenAccount *common.Account) (int64, error) { - tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "CalculateUsdCostBasisFromCache") - tracer.AddAttribute("account", tokenAccount.PublicKey().ToBase58()) - defer tracer.End() - - if enableLedgerReads.Get(ctx) { - balanceRecord, err := data.GetBalance(ctx, tokenAccount.PublicKey().ToBase58()) - if err == nil && balanceRecord.IsBackfilled { - if !balanceRecord.IsLocked { - tracer.OnError(ErrNotManagedByCode) - return 0, ErrNotManagedByCode - } - return balanceRecord.UsdCostBasis, nil - } else if err != nil && err != balance.ErrRecordNotFound { - tracer.OnError(err) - return 0, err - } - } - - res, err := legacyUsdCostBasis(ctx, data, tokenAccount.PublicKey().ToBase58()) - if err != nil { - tracer.OnError(err) - return 0, err - } - return res, nil -} - -// BatchCalculateUsdCostBasisFromCache is like CalculateUsdCostBasisFromCache, -// but for a set of token accounts. -func BatchCalculateUsdCostBasisFromCache(ctx context.Context, data ocp_data.Provider, tokenAccounts ...*common.Account) (map[string]int64, error) { - tracer := metrics.TraceMethodCall(ctx, metricsPackageName, "BatchCalculateUsdCostBasisFromCache") - defer tracer.End() - - tokenAccountStrings := make([]string, len(tokenAccounts)) - for i, tokenAccount := range tokenAccounts { - tokenAccountStrings[i] = tokenAccount.PublicKey().ToBase58() - } - balanceRecords := make(map[string]*balance.Record) - if enableLedgerReads.Get(ctx) { - var err error - balanceRecords, err = data.GetBalanceBatch(ctx, tokenAccountStrings...) - if err != nil { - tracer.OnError(err) - return nil, err + res := make(map[string]*Balance, len(balanceRecords)) + for _, balanceRecord := range balanceRecords { + if cached, ok := balanceFromRecord(balanceRecord); ok { + res[balanceRecord.TokenAccount] = cached } } - - res := make(map[string]int64, len(tokenAccounts)) - for _, tokenAccount := range tokenAccountStrings { - balanceRecord, ok := balanceRecords[tokenAccount] - if ok && balanceRecord.IsBackfilled { - if !balanceRecord.IsLocked { - tracer.OnError(ErrNotManagedByCode) - return nil, ErrNotManagedByCode - } - res[tokenAccount] = balanceRecord.UsdCostBasis - continue - } - - // todo: The legacy calculation has a batch variant by owner, but the - // fallback is temporary and per-owner batching doesn't map onto - // token accounts without an extra lookup anyway. - usdCostBasis, err := legacyUsdCostBasis(ctx, data, tokenAccount) - if err != nil { - tracer.OnError(err) - return nil, err - } - res[tokenAccount] = usdCostBasis - } return res, nil } -// legacyUsdCostBasis derives a token account's cost basis from the owner-level -// intent aggregate, which is only defined for primary accounts. -func legacyUsdCostBasis(ctx context.Context, data ocp_data.Provider, tokenAccount string) (int64, error) { - accountInfoRecord, err := data.GetAccountInfoByTokenAddress(ctx, tokenAccount) - if err == account.ErrAccountInfoNotFound { - return 0, nil - } else if err != nil { - return 0, err - } - - if accountInfoRecord.AccountType != commonpb.AccountType_PRIMARY { - return 0, nil - } - - usdCostBasis, err := data.GetUsdCostBasis(ctx, accountInfoRecord.OwnerAccount, accountInfoRecord.MintAccount) - if err != nil { - return 0, err - } - return int64(math.Round(usdCostBasis * balance.UsdQuarksPerUnit)), nil -} - -func quarksFromRecord(record *balance.Record) (uint64, error) { - // Callers reject unlocked vaults on the timelock record before reaching - // here, so this only guards against a record that disagrees with it. +// balanceFromRecord reports a ledger record as a Balance, and whether the +// ledger still maintains it. Once a vault unlocks, funds can move on chain +// without an intent, so the record's balance must not be trusted or +// aggregated. +func balanceFromRecord(record *balance.Record) (*Balance, bool) { if !record.IsLocked { - return 0, ErrNotManagedByCode - } - if record.Quarks < 0 { - return 0, ErrNegativeBalance + return nil, false } - return uint64(record.Quarks), nil -} - -// NetBalanceFromIntentActionsBatch is a balance calculation strategy that incorporates -// the net balance by applying payment intents to the current balance. -func NetBalanceFromIntentActionsBatch(ctx context.Context, data ocp_data.Provider) BatchStrategy { - return func(ctx context.Context, tokenAccounts []string, state *BatchState) (*BatchState, error) { - netBalanceByAccount, err := data.GetNetBalanceFromActionsBatch(ctx, tokenAccounts...) - if err != nil { - return nil, errors.Wrap(err, "error getting net balance from intent actions") - } - for tokenAccount, netBalance := range netBalanceByAccount { - state.current[tokenAccount] += netBalance - } - - return state, nil - } -} - -// FundingFromExternalDepositsBatch is a balance calculation strategy that adds -// funding from deposits from external accounts. -func FundingFromExternalDepositsBatch(ctx context.Context, data ocp_data.Provider) BatchStrategy { - return func(ctx context.Context, tokenAccounts []string, state *BatchState) (*BatchState, error) { - amountByAccount, err := data.GetTotalExternalDepositedAmountInQuarksBatch(ctx, tokenAccounts...) - if err != nil { - return nil, errors.Wrap(err, "error getting external deposit amount") - } - - for tokenAccount, amount := range amountByAccount { - state.current[tokenAccount] += int64(amount) - } - - return state, nil - } + return &Balance{ + MintAccount: record.MintAccount, + Quarks: record.Quarks, + UsdCostBasis: record.UsdCostBasis, + }, true } func (s Source) String() string { diff --git a/ocp/balance/calculator_test.go b/ocp/balance/calculator_test.go index a03223d..54ad38d 100644 --- a/ocp/balance/calculator_test.go +++ b/ocp/balance/calculator_test.go @@ -2,656 +2,122 @@ package balance import ( "context" - "fmt" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" - "github.com/code-payments/ocp-server/config/memory" - "github.com/code-payments/ocp-server/config/wrapper" - currency_lib "github.com/code-payments/ocp-server/currency" "github.com/code-payments/ocp-server/ocp/common" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/account" - "github.com/code-payments/ocp-server/ocp/data/action" "github.com/code-payments/ocp-server/ocp/data/balance" - "github.com/code-payments/ocp-server/ocp/data/deposit" - "github.com/code-payments/ocp-server/ocp/data/intent" - "github.com/code-payments/ocp-server/ocp/data/transaction" timelock_token_v1 "github.com/code-payments/ocp-server/solana/timelock/v1" "github.com/code-payments/ocp-server/testutil" ) -func TestDefaultCalculationMethods_NewCodeAccount(t *testing.T) { - env := setupBalanceTestEnv(t) - - vmConfig := testutil.NewRandomVmConfig(t, true) - newOwnerAccount := testutil.NewRandomAccount(t) - newTokenAccount, err := newOwnerAccount.ToTimelockVault(vmConfig) - require.NoError(t, err) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{newOwnerAccount}, - } - - setupBalanceTestData(t, env, data) - - accountRecords, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, newOwnerAccount) - require.NoError(t, err) - - balance, err := CalculateFromCache(env.ctx, env.data, newTokenAccount) - require.NoError(t, err) - assert.EqualValues(t, 0, balance) - - balanceByAccount, err := BatchCalculateFromCacheWithAccountRecords(env.ctx, env.data, accountRecords[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0]) - require.NoError(t, err) - require.Len(t, balanceByAccount, 1) - assert.EqualValues(t, 0, balanceByAccount[newTokenAccount.PublicKey().ToBase58()]) - - balanceByAccount, err = BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, newTokenAccount) - require.NoError(t, err) - require.Len(t, balanceByAccount, 1) - assert.EqualValues(t, 0, balanceByAccount[newTokenAccount.PublicKey().ToBase58()]) -} - -func TestDefaultCalculationMethods_DepositFromExternalWallet(t *testing.T) { - env := setupBalanceTestEnv(t) - - vmConfig := testutil.NewRandomVmConfig(t, true) - owner := testutil.NewRandomAccount(t) - depositAccount, err := owner.ToTimelockVault(vmConfig) - require.NoError(t, err) - - externalAccount := testutil.NewRandomAccount(t) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{owner}, - transactions: []balanceTestTransaction{ - // The following entries are added to the balance - {source: externalAccount, destination: depositAccount, quantity: 1, transactionState: transaction.ConfirmationFinalized}, - {source: externalAccount, destination: depositAccount, quantity: 10, transactionState: transaction.ConfirmationFinalized}, - // The following entries aren't added to the balance because they aren't finalized - {source: externalAccount, destination: depositAccount, quantity: 100, transactionState: transaction.ConfirmationFailed}, - {source: externalAccount, destination: depositAccount, quantity: 1000, transactionState: transaction.ConfirmationPending}, - {source: externalAccount, destination: depositAccount, quantity: 10000, transactionState: transaction.ConfirmationUnknown}, - }, - } - setupBalanceTestData(t, env, data) - - balance, err := CalculateFromCache(env.ctx, env.data, depositAccount) - require.NoError(t, err) - assert.EqualValues(t, 11, balance) - - accountRecords, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, owner) - require.NoError(t, err) - - balanceByAccount, err := BatchCalculateFromCacheWithAccountRecords(env.ctx, env.data, accountRecords[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0]) - require.NoError(t, err) - require.Len(t, balanceByAccount, 1) - assert.EqualValues(t, 11, balanceByAccount[depositAccount.PublicKey().ToBase58()]) - - balanceByAccount, err = BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, depositAccount) - require.NoError(t, err) - require.Len(t, balanceByAccount, 1) - assert.EqualValues(t, 11, balanceByAccount[depositAccount.PublicKey().ToBase58()]) -} - -func TestDefaultCalculationMethods_MultipleIntents(t *testing.T) { - env := setupBalanceTestEnv(t) - - vmConfig := testutil.NewRandomVmConfig(t, true) - - owner1 := testutil.NewRandomAccount(t) - a1, err := owner1.ToTimelockVault(vmConfig) - require.NoError(t, err) - - owner2 := testutil.NewRandomAccount(t) - a2, err := owner2.ToTimelockVault(vmConfig) - require.NoError(t, err) - - owner3 := testutil.NewRandomAccount(t) - a3, err := owner3.ToTimelockVault(vmConfig) - require.NoError(t, err) - - owner4 := testutil.NewRandomAccount(t) - a4, err := owner4.ToTimelockVault(vmConfig) - require.NoError(t, err) - - externalAccount := testutil.NewRandomAccount(t) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{owner1, owner2, owner3, owner4}, - transactions: []balanceTestTransaction{ - // Fund account a1 through a4 with an external deposit - {source: externalAccount, destination: a1, quantity: 1, transactionState: transaction.ConfirmationFinalized}, - {source: externalAccount, destination: a2, quantity: 10, transactionState: transaction.ConfirmationFinalized}, - {source: externalAccount, destination: a3, quantity: 100, transactionState: transaction.ConfirmationFinalized}, - {source: externalAccount, destination: a4, quantity: 1000, transactionState: transaction.ConfirmationFinalized}, - // Confirmed intents are incorporated into balance calculations - {source: a4, destination: a1, quantity: 1, intentID: "i1", intentState: intent.StateConfirmed, actionState: action.StateConfirmed, transactionState: transaction.ConfirmationFinalized}, - {source: a4, destination: a1, quantity: 2, intentID: "i2", intentState: intent.StateConfirmed, actionState: action.StateConfirmed, transactionState: transaction.ConfirmationFinalized}, - // Pending intents are incorporated into balance calculations - {source: a4, destination: a2, quantity: 3, intentID: "i3", intentState: intent.StatePending, actionState: action.StatePending}, - {source: a4, destination: a2, quantity: 4, intentID: "i4", intentState: intent.StatePending, actionState: action.StatePending}, - // Failed intents are incorporated into balance calculations. We'll - // always make the user whole. - {source: a4, destination: a3, quantity: 5, intentID: "i5", intentState: intent.StateFailed, actionState: action.StateFailed}, - {source: a4, destination: a3, quantity: 6, intentID: "i6", intentState: intent.StateFailed, actionState: action.StateFailed}, - // Intents in the unknown state are incorporated differently depending - // on the intent type, since it infers which intent system it came from. - // Legacy intents are not incorporated, as the intent is not committed by - // the client. Intents could theoretically by in the unknown state under - // the new system, but we should limit this as much as possible. - {source: a4, destination: a1, quantity: 7, intentID: "i7", intentState: intent.StateUnknown, actionState: action.StateUnknown}, - // Revoked intents are not incorporated into balance calculations. - {source: a4, destination: a2, quantity: 8, intentID: "i8", intentState: intent.StateRevoked, actionState: action.StateRevoked}, - }, - } - - setupBalanceTestData(t, env, data) - - balance, err := CalculateFromCache(env.ctx, env.data, a1) - require.NoError(t, err) - assert.EqualValues(t, 11, balance) - - balance, err = CalculateFromCache(env.ctx, env.data, a2) - require.NoError(t, err) - assert.EqualValues(t, 17, balance) - - balance, err = CalculateFromCache(env.ctx, env.data, a3) - require.NoError(t, err) - assert.EqualValues(t, 111, balance) - - balance, err = CalculateFromCache(env.ctx, env.data, a4) - require.NoError(t, err) - assert.EqualValues(t, 972, balance) - - accountRecords1, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, owner1) - require.NoError(t, err) - - accountRecords2, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, owner2) - require.NoError(t, err) - - accountRecords3, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, owner3) - require.NoError(t, err) - - accountRecords4, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, owner4) - require.NoError(t, err) - - balanceByAccount, err := BatchCalculateFromCacheWithAccountRecords(env.ctx, env.data, accountRecords1[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0], accountRecords2[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0], accountRecords3[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0], accountRecords4[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0]) - require.NoError(t, err) - require.Len(t, balanceByAccount, 4) - assert.EqualValues(t, 11, balanceByAccount[a1.PublicKey().ToBase58()]) - assert.EqualValues(t, 17, balanceByAccount[a2.PublicKey().ToBase58()]) - assert.EqualValues(t, 111, balanceByAccount[a3.PublicKey().ToBase58()]) - assert.EqualValues(t, 972, balanceByAccount[a4.PublicKey().ToBase58()]) - - balanceByAccount, err = BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, a1, a2, a3, a4) - require.NoError(t, err) - require.Len(t, balanceByAccount, 4) - assert.EqualValues(t, 11, balanceByAccount[a1.PublicKey().ToBase58()]) - assert.EqualValues(t, 17, balanceByAccount[a2.PublicKey().ToBase58()]) - assert.EqualValues(t, 111, balanceByAccount[a3.PublicKey().ToBase58()]) - assert.EqualValues(t, 972, balanceByAccount[a4.PublicKey().ToBase58()]) -} - -func TestDefaultCalculationMethods_BackAndForth(t *testing.T) { +func TestDefaultCalculationMethods_BalanceRecord(t *testing.T) { env := setupBalanceTestEnv(t) - vmConfig := testutil.NewRandomVmConfig(t, true) - - owner1 := testutil.NewRandomAccount(t) - a1, err := owner1.ToTimelockVault(vmConfig) - require.NoError(t, err) - - owner2 := testutil.NewRandomAccount(t) - a2, err := owner2.ToTimelockVault(vmConfig) - require.NoError(t, err) - - externalAccount := testutil.NewRandomAccount(t) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{owner1, owner2}, - transactions: []balanceTestTransaction{ - // Fund account a1 through an external deposit - {source: externalAccount, destination: a1, quantity: 1, transactionState: transaction.ConfirmationFinalized}, - // Setup a set of intents that result in back and forth movement of the Kin - {source: a1, destination: a2, quantity: 1, intentID: "i1", intentState: intent.StateConfirmed, actionState: action.StateConfirmed, transactionState: transaction.ConfirmationFinalized}, - {source: a2, destination: a1, quantity: 1, intentID: "i2", intentState: intent.StateConfirmed, actionState: action.StateConfirmed, transactionState: transaction.ConfirmationFinalized}, - {source: a1, destination: a2, quantity: 1, intentID: "i3", intentState: intent.StatePending, actionState: action.StatePending}, - {source: a2, destination: a1, quantity: 1, intentID: "i4", intentState: intent.StatePending, actionState: action.StatePending}, - {source: a1, destination: a2, quantity: 1, intentID: "i5", intentState: intent.StatePending, actionState: action.StatePending}, - }, + first := newBalanceTestAccount(t, env) + second := newBalanceTestAccount(t, env) + + // Quarks and cost basis come from the same record, so they can't disagree + saveBalanceTestRecord(t, env, first, &balance.Record{Quarks: 42, UsdCostBasis: 4_200_000, IsOpen: true, IsLocked: true}) + saveBalanceTestRecord(t, env, second, &balance.Record{Quarks: 0, UsdCostBasis: -123456, IsOpen: true, IsLocked: true}) + + for _, tc := range []struct { + testAccount *balanceTestAccount + quarks uint64 + usdCostBasis int64 + }{ + {first, 42, 4_200_000}, + {second, 0, -123456}, + } { + actual, err := CalculateFromCache(env.ctx, env.data, tc.testAccount.tokenAccount) + require.NoError(t, err) + assert.EqualValues(t, tc.quarks, actual) } - setupBalanceTestData(t, env, data) - - balance, err := CalculateFromCache(env.ctx, env.data, a1) - require.NoError(t, err) - assert.EqualValues(t, 0, balance) - - balance, err = CalculateFromCache(env.ctx, env.data, a2) + balanceByAccount, err := BatchCalculateFromCache(env.ctx, env.data, first.tokenAccount, second.tokenAccount) require.NoError(t, err) - assert.EqualValues(t, 1, balance) - - accountRecords1, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, owner1) - require.NoError(t, err) - - accountRecords2, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, owner2) - require.NoError(t, err) - - balanceByAccount, err := BatchCalculateFromCacheWithAccountRecords(env.ctx, env.data, accountRecords1[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0], accountRecords2[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0]) - require.NoError(t, err) - require.Len(t, balanceByAccount, 2) - assert.EqualValues(t, 0, balanceByAccount[a1.PublicKey().ToBase58()]) - assert.EqualValues(t, 1, balanceByAccount[a2.PublicKey().ToBase58()]) - - balanceByAccount, err = BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, a1, a2) - require.NoError(t, err) - require.Len(t, balanceByAccount, 2) - assert.EqualValues(t, 0, balanceByAccount[a1.PublicKey().ToBase58()]) - assert.EqualValues(t, 1, balanceByAccount[a2.PublicKey().ToBase58()]) + assert.Equal(t, map[string]*Balance{ + first.tokenAccount.PublicKey().ToBase58(): {MintAccount: first.mint(), Quarks: 42, UsdCostBasis: 4_200_000}, + second.tokenAccount.PublicKey().ToBase58(): {MintAccount: second.mint(), Quarks: 0, UsdCostBasis: -123456}, + }, balanceByAccount) } -func TestDefaultCalculationMethods_SelfPayments(t *testing.T) { +func TestDefaultCalculationMethods_MissingBalanceRecord(t *testing.T) { env := setupBalanceTestEnv(t) - vmConfig := testutil.NewRandomVmConfig(t, true) - ownerAccount := testutil.NewRandomAccount(t) - tokenAccount, err := ownerAccount.ToTimelockVault(vmConfig) - require.NoError(t, err) - - externalAccount := testutil.NewRandomAccount(t) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{ownerAccount}, - transactions: []balanceTestTransaction{ - // Fund account the token account through an external deposit - {source: externalAccount, destination: tokenAccount, quantity: 1, transactionState: transaction.ConfirmationFinalized}, - // Setup a set of intents that result in self-payments and no-ops to - // the balance calculation - {source: tokenAccount, destination: tokenAccount, quantity: 1, intentID: "i1", intentState: intent.StateConfirmed, actionState: action.StateConfirmed, transactionState: transaction.ConfirmationFinalized}, - {source: tokenAccount, destination: tokenAccount, quantity: 1, intentID: "i2", intentState: intent.StateConfirmed, actionState: action.StateConfirmed, transactionState: transaction.ConfirmationFinalized}, - {source: tokenAccount, destination: tokenAccount, quantity: 1, intentID: "i3", intentState: intent.StatePending, actionState: action.StatePending}, - {source: tokenAccount, destination: tokenAccount, quantity: 1, intentID: "i4", intentState: intent.StatePending, actionState: action.StatePending}, - }, - } - - setupBalanceTestData(t, env, data) - - balance, err := CalculateFromCache(env.ctx, env.data, tokenAccount) - require.NoError(t, err) - assert.EqualValues(t, 1, balance) - - accountRecords, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, ownerAccount) - require.NoError(t, err) + tracked := newBalanceTestAccount(t, env) + saveBalanceTestRecord(t, env, tracked, &balance.Record{Quarks: 42, IsOpen: true, IsLocked: true}) + untracked := newBalanceTestAccount(t, env) - balanceByAccount, err := BatchCalculateFromCacheWithAccountRecords(env.ctx, env.data, accountRecords[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0]) - require.NoError(t, err) - require.Len(t, balanceByAccount, 1) - assert.EqualValues(t, 1, balanceByAccount[tokenAccount.PublicKey().ToBase58()]) + // The ledger record is the whole answer, so an account without one simply + // isn't managed by Code + _, err := CalculateFromCache(env.ctx, env.data, untracked.tokenAccount) + assert.Equal(t, ErrNotManagedByCode, err) - balanceByAccount, err = BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, tokenAccount) + // The batch variant says so by omission, and the rest of the batch still + // resolves + balanceByAccount, err := BatchCalculateFromCache(env.ctx, env.data, tracked.tokenAccount, untracked.tokenAccount) require.NoError(t, err) require.Len(t, balanceByAccount, 1) - assert.EqualValues(t, 1, balanceByAccount[tokenAccount.PublicKey().ToBase58()]) + assert.EqualValues(t, 42, balanceByAccount[tracked.tokenAccount.PublicKey().ToBase58()].Quarks) } func TestDefaultCalculationMethods_NotManagedByCode(t *testing.T) { env := setupBalanceTestEnv(t) - vmConfig := testutil.NewRandomVmConfig(t, true) - ownerAccount := testutil.NewRandomAccount(t) - tokenAccount, err := ownerAccount.ToTimelockVault(vmConfig) - require.NoError(t, err) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{ownerAccount}, - } - - setupBalanceTestData(t, env, data) + testAccount := newBalanceTestAccount(t, env) + saveBalanceTestRecord(t, env, testAccount, &balance.Record{Quarks: 42, IsOpen: true, IsLocked: true}) - timelockRecord, err := env.data.GetTimelockByVault(env.ctx, tokenAccount.PublicKey().ToBase58()) + // The vault unlocks. In production the timelock and ledger records move in + // the same transaction, so they can't disagree. + timelockRecord, err := env.data.GetTimelockByVault(env.ctx, testAccount.tokenAccount.PublicKey().ToBase58()) require.NoError(t, err) timelockRecord.VaultState = timelock_token_v1.StateWaitingForTimeout timelockRecord.Block += 1 require.NoError(t, env.data.SaveTimelock(env.ctx, timelockRecord)) + require.NoError(t, env.data.MarkBalanceAsUnlocked(env.ctx, testAccount.tokenAccount.PublicKey().ToBase58())) - accountRecords, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, ownerAccount) - require.NoError(t, err) - - _, err = CalculateFromCache(env.ctx, env.data, tokenAccount) - assert.Equal(t, ErrNotManagedByCode, err) - - _, err = BatchCalculateFromCacheWithAccountRecords(env.ctx, env.data, accountRecords[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0]) + // A record for an unlocked vault holds the last managed state rather than + // a live balance, so it is refused outright and omitted from a batch + _, err = CalculateFromCache(env.ctx, env.data, testAccount.tokenAccount) assert.Equal(t, ErrNotManagedByCode, err) - _, err = BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, tokenAccount) - assert.Equal(t, ErrNotManagedByCode, err) -} - -func TestDefaultCalculationMethods_BalanceRecord(t *testing.T) { - env := setupBalanceTestEnv(t) - enableLedgerReadsForTest(t) - - vmConfig := testutil.NewRandomVmConfig(t, true) - backfilledOwner := testutil.NewRandomAccount(t) - backfilledAccount, err := backfilledOwner.ToTimelockVault(vmConfig) - require.NoError(t, err) - pendingOwner := testutil.NewRandomAccount(t) - pendingAccount, err := pendingOwner.ToTimelockVault(vmConfig) - require.NoError(t, err) - legacyOwner := testutil.NewRandomAccount(t) - legacyAccount, err := legacyOwner.ToTimelockVault(vmConfig) - require.NoError(t, err) - - externalAccount := testutil.NewRandomAccount(t) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{backfilledOwner, pendingOwner, legacyOwner}, - transactions: []balanceTestTransaction{ - {source: externalAccount, destination: backfilledAccount, quantity: 11, transactionState: transaction.ConfirmationFinalized}, - {source: externalAccount, destination: pendingAccount, quantity: 22, transactionState: transaction.ConfirmationFinalized}, - {source: externalAccount, destination: legacyAccount, quantity: 33, transactionState: transaction.ConfirmationFinalized}, - }, - } - - setupBalanceTestData(t, env, data) - - // A backfilled record is authoritative, even where it disagrees with history - require.NoError(t, env.data.CreateBalance(env.ctx, &balance.Record{ - TokenAccount: backfilledAccount.PublicKey().ToBase58(), - OwnerAccount: backfilledOwner.PublicKey().ToBase58(), - MintAccount: vmConfig.Mint.PublicKey().ToBase58(), - Quarks: 42, - IsOpen: true, - IsLocked: true, - IsBackfilled: true, - })) - - // A record that isn't backfilled is ignored in favour of history - require.NoError(t, env.data.CreateBalance(env.ctx, &balance.Record{ - TokenAccount: pendingAccount.PublicKey().ToBase58(), - OwnerAccount: pendingOwner.PublicKey().ToBase58(), - MintAccount: vmConfig.Mint.PublicKey().ToBase58(), - Quarks: -5, - IsOpen: true, - })) - - expected := map[string]uint64{ - backfilledAccount.PublicKey().ToBase58(): 42, - pendingAccount.PublicKey().ToBase58(): 22, - legacyAccount.PublicKey().ToBase58(): 33, - } - - for tokenAccount, expectedQuarks := range expected { - account, err := common.NewAccountFromPublicKeyString(tokenAccount) - require.NoError(t, err) - - actual, err := CalculateFromCache(env.ctx, env.data, account) - require.NoError(t, err) - assert.EqualValues(t, expectedQuarks, actual, tokenAccount) - } - - balanceByAccount, err := BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, backfilledAccount, pendingAccount, legacyAccount) - require.NoError(t, err) - assert.Equal(t, expected, balanceByAccount) - - var allAccountRecords []*common.AccountRecords - for _, owner := range data.codeUsers { - accountRecords, err := common.GetLatestTokenAccountRecordsForOwner(env.ctx, env.data, owner) - require.NoError(t, err) - allAccountRecords = append(allAccountRecords, accountRecords[vmConfig.Mint.PublicKey().ToBase58()][commonpb.AccountType_PRIMARY][0]) - } - balanceByAccount, err = BatchCalculateFromCacheWithAccountRecords(env.ctx, env.data, allAccountRecords...) - require.NoError(t, err) - assert.Equal(t, expected, balanceByAccount) -} - -func TestDefaultCalculationMethods_BalanceWithUsdCostBasis(t *testing.T) { - env := setupBalanceTestEnv(t) - enableLedgerReadsForTest(t) - - vmConfig := testutil.NewRandomVmConfig(t, true) - backfilledOwner := testutil.NewRandomAccount(t) - backfilledAccount, err := backfilledOwner.ToTimelockVault(vmConfig) - require.NoError(t, err) - legacyOwner := testutil.NewRandomAccount(t) - legacyAccount, err := legacyOwner.ToTimelockVault(vmConfig) + balanceByAccount, err := BatchCalculateFromCache(env.ctx, env.data, testAccount.tokenAccount) require.NoError(t, err) - - externalAccount := testutil.NewRandomAccount(t) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{backfilledOwner, legacyOwner}, - transactions: []balanceTestTransaction{ - {source: externalAccount, destination: backfilledAccount, quantity: 11, transactionState: transaction.ConfirmationFinalized}, - {source: externalAccount, destination: legacyAccount, quantity: 33, transactionState: transaction.ConfirmationFinalized}, - }, - } - - setupBalanceTestData(t, env, data) - - // Both values come from the same record for a backfilled account, even - // where it disagrees with history - require.NoError(t, env.data.CreateBalance(env.ctx, &balance.Record{ - TokenAccount: backfilledAccount.PublicKey().ToBase58(), - OwnerAccount: backfilledOwner.PublicKey().ToBase58(), - MintAccount: vmConfig.Mint.PublicKey().ToBase58(), - Quarks: 42, - UsdCostBasis: 4_200_000, - IsOpen: true, - IsLocked: true, - IsBackfilled: true, - })) - - accountRecordsBatch := make([]*common.AccountRecords, 0) - for _, tokenAccount := range []*common.Account{backfilledAccount, legacyAccount} { - generalRecord, err := env.data.GetAccountInfoByTokenAddress(env.ctx, tokenAccount.PublicKey().ToBase58()) - require.NoError(t, err) - timelockRecord, err := env.data.GetTimelockByVault(env.ctx, tokenAccount.PublicKey().ToBase58()) - require.NoError(t, err) - accountRecordsBatch = append(accountRecordsBatch, &common.AccountRecords{ - General: generalRecord, - Timelock: timelockRecord, - }) - } - - res, err := BatchCalculateWithUsdCostBasisFromCache(env.ctx, env.data, accountRecordsBatch...) - require.NoError(t, err) - require.Len(t, res, 2) - - cached := res[backfilledAccount.PublicKey().ToBase58()] - require.NotNil(t, cached) - assert.EqualValues(t, 42, cached.Quarks) - assert.EqualValues(t, 4_200_000, cached.UsdCostBasis) - - // An account without a backfilled record falls back to the legacy - // aggregates for both values - cached = res[legacyAccount.PublicKey().ToBase58()] - require.NotNil(t, cached) - assert.EqualValues(t, 33, cached.Quarks) - assert.EqualValues(t, 0, cached.UsdCostBasis) // deposits aren't primary-owner intents in this fixture + assert.Empty(t, balanceByAccount) } -func TestDefaultCalculationMethods_UnlockedBalanceRecord(t *testing.T) { +func TestDefaultCalculationMethods_ByOwner(t *testing.T) { env := setupBalanceTestEnv(t) - enableLedgerReadsForTest(t) - vmConfig := testutil.NewRandomVmConfig(t, true) owner := testutil.NewRandomAccount(t) - tokenAccount, err := owner.ToTimelockVault(vmConfig) - require.NoError(t, err) + coreMint := newBalanceTestAccountForOwner(t, env, owner, testutil.NewRandomVmConfig(t, true)) + otherMint := newBalanceTestAccountForOwner(t, env, owner, testutil.NewRandomVmConfig(t, false)) + unlocked := newBalanceTestAccountForOwner(t, env, owner, testutil.NewRandomVmConfig(t, false)) - externalAccount := testutil.NewRandomAccount(t) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{owner}, - transactions: []balanceTestTransaction{ - {source: externalAccount, destination: tokenAccount, quantity: 11, transactionState: transaction.ConfirmationFinalized}, - }, - } - - setupBalanceTestData(t, env, data) - - // A backfilled record for an unlocked vault holds the last managed state, - // not a live balance, so it is refused even though the timelock record - // still passes the managed check. That pairing is inconsistent by - // construction: the timelock check normally rejects first, so the fixture - // exists to exercise the record's own guard. - require.NoError(t, env.data.CreateBalance(env.ctx, &balance.Record{ - TokenAccount: tokenAccount.PublicKey().ToBase58(), - OwnerAccount: owner.PublicKey().ToBase58(), - MintAccount: vmConfig.Mint.PublicKey().ToBase58(), - Quarks: 42, - UsdCostBasis: 4_200_000, - IsOpen: true, - IsLocked: false, - IsBackfilled: true, - })) - - _, err = CalculateFromCache(env.ctx, env.data, tokenAccount) - assert.Equal(t, ErrNotManagedByCode, err) + saveBalanceTestRecord(t, env, coreMint, &balance.Record{Quarks: 42, UsdCostBasis: 4_200_000, IsOpen: true, IsLocked: true}) + saveBalanceTestRecord(t, env, otherMint, &balance.Record{Quarks: 33, UsdCostBasis: -123456, IsOpen: true, IsLocked: true}) + saveBalanceTestRecord(t, env, unlocked, &balance.Record{Quarks: 99, IsOpen: true}) - _, err = BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, tokenAccount) - assert.Equal(t, ErrNotManagedByCode, err) - - _, err = CalculateUsdCostBasisFromCache(env.ctx, env.data, tokenAccount) - assert.Equal(t, ErrNotManagedByCode, err) - - _, err = BatchCalculateUsdCostBasisFromCache(env.ctx, env.data, tokenAccount) - assert.Equal(t, ErrNotManagedByCode, err) -} - -func TestDefaultCalculationMethods_BalanceRecordReadsDisabled(t *testing.T) { - env := setupBalanceTestEnv(t) - disableLedgerReadsForTest(t) - - vmConfig := testutil.NewRandomVmConfig(t, true) - owner := testutil.NewRandomAccount(t) - tokenAccount, err := owner.ToTimelockVault(vmConfig) + // Every account the ledger manages for the owner is reported with the mint + // it holds. The unlocked one is omitted, since its balance is stale. + balanceByAccount, err := BatchCalculateFromCacheByOwner(env.ctx, env.data, owner) require.NoError(t, err) + assert.Equal(t, map[string]*Balance{ + coreMint.tokenAccount.PublicKey().ToBase58(): {MintAccount: coreMint.mint(), Quarks: 42, UsdCostBasis: 4_200_000}, + otherMint.tokenAccount.PublicKey().ToBase58(): {MintAccount: otherMint.mint(), Quarks: 33, UsdCostBasis: -123456}, + }, balanceByAccount) - externalAccount := testutil.NewRandomAccount(t) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{owner}, - transactions: []balanceTestTransaction{ - {source: externalAccount, destination: tokenAccount, quantity: 11, transactionState: transaction.ConfirmationFinalized}, - }, - } - - setupBalanceTestData(t, env, data) - - // A backfilled record exists, but reads are disabled, so history wins - require.NoError(t, env.data.CreateBalance(env.ctx, &balance.Record{ - TokenAccount: tokenAccount.PublicKey().ToBase58(), - OwnerAccount: owner.PublicKey().ToBase58(), - MintAccount: vmConfig.Mint.PublicKey().ToBase58(), - Quarks: 42, - UsdCostBasis: 123, - IsOpen: true, - IsLocked: true, - IsBackfilled: true, - })) - - actual, err := CalculateFromCache(env.ctx, env.data, tokenAccount) + // An owner the ledger holds nothing for is empty rather than an error + balanceByAccount, err = BatchCalculateFromCacheByOwner(env.ctx, env.data, testutil.NewRandomAccount(t)) require.NoError(t, err) - assert.EqualValues(t, 11, actual) - - balanceByAccount, err := BatchCalculateFromCacheWithTokenAccounts(env.ctx, env.data, tokenAccount) - require.NoError(t, err) - assert.EqualValues(t, 11, balanceByAccount[tokenAccount.PublicKey().ToBase58()]) - - usdCostBasis, err := CalculateUsdCostBasisFromCache(env.ctx, env.data, tokenAccount) - require.NoError(t, err) - assert.EqualValues(t, 0, usdCostBasis) -} - -func TestUsdCostBasisCalculationMethods(t *testing.T) { - env := setupBalanceTestEnv(t) - enableLedgerReadsForTest(t) - - vmConfig := testutil.NewRandomVmConfig(t, true) - backfilledOwner := testutil.NewRandomAccount(t) - backfilledAccount, err := backfilledOwner.ToTimelockVault(vmConfig) - require.NoError(t, err) - legacyOwner := testutil.NewRandomAccount(t) - legacyAccount, err := legacyOwner.ToTimelockVault(vmConfig) - require.NoError(t, err) - - data := &balanceTestData{ - vmConfig: vmConfig, - codeUsers: []*common.Account{backfilledOwner, legacyOwner}, - } - - setupBalanceTestData(t, env, data) - - require.NoError(t, env.data.CreateBalance(env.ctx, &balance.Record{ - TokenAccount: backfilledAccount.PublicKey().ToBase58(), - OwnerAccount: backfilledOwner.PublicKey().ToBase58(), - MintAccount: vmConfig.Mint.PublicKey().ToBase58(), - UsdCostBasis: -123456, - IsOpen: true, - IsLocked: true, - IsBackfilled: true, - })) - - // The legacy calculation is owner-level, and derived from intents - require.NoError(t, env.data.SaveIntent(env.ctx, &intent.Record{ - IntentId: testutil.NewRandomAccount(t).PublicKey().ToBase58(), - IntentType: intent.ExternalDeposit, - MintAccount: vmConfig.Mint.PublicKey().ToBase58(), - InitiatorOwnerAccount: legacyOwner.PublicKey().ToBase58(), - ExternalDepositMetadata: &intent.ExternalDepositMetadata{ - DestinationTokenAccount: legacyAccount.PublicKey().ToBase58(), - Quantity: 1, - ExchangeCurrency: currency_lib.USD, - ExchangeRate: 1.0, - NativeAmount: 1.5, - UsdMarketValue: 1.5, - }, - State: intent.StateConfirmed, - CreatedAt: time.Now(), - })) - - expected := map[string]int64{ - backfilledAccount.PublicKey().ToBase58(): -123456, - legacyAccount.PublicKey().ToBase58(): 1_500_000, - } - - for tokenAccount, expectedUsdCostBasis := range expected { - account, err := common.NewAccountFromPublicKeyString(tokenAccount) - require.NoError(t, err) - - actual, err := CalculateUsdCostBasisFromCache(env.ctx, env.data, account) - require.NoError(t, err) - assert.EqualValues(t, expectedUsdCostBasis, actual, tokenAccount) - } - - usdCostBasisByAccount, err := BatchCalculateUsdCostBasisFromCache(env.ctx, env.data, backfilledAccount, legacyAccount) - require.NoError(t, err) - assert.Equal(t, expected, usdCostBasisByAccount) - - // Accounts unknown to the system have no cost basis - unknownAccount := testutil.NewRandomAccount(t) - actual, err := CalculateUsdCostBasisFromCache(env.ctx, env.data, unknownAccount) - require.NoError(t, err) - assert.EqualValues(t, 0, actual) + assert.Empty(t, balanceByAccount) } func TestDefaultCalculation_ExternalAccount(t *testing.T) { @@ -663,42 +129,15 @@ func TestDefaultCalculation_ExternalAccount(t *testing.T) { // Note: not possible with batch method, since we wouldn't have account records } -func enableLedgerReadsForTest(t *testing.T) { - previous := enableLedgerReads - enableLedgerReads = wrapper.NewBoolConfig(memory.NewConfig(true), defaultEnableLedgerReads) - t.Cleanup(func() { - enableLedgerReads = previous - }) -} - -func disableLedgerReadsForTest(t *testing.T) { - previous := enableLedgerReads - enableLedgerReads = wrapper.NewBoolConfig(memory.NewConfig(false), defaultEnableLedgerReads) - t.Cleanup(func() { - enableLedgerReads = previous - }) -} - type balanceTestEnv struct { ctx context.Context data ocp_data.Provider } -type balanceTestData struct { +type balanceTestAccount struct { vmConfig *common.VmConfig - codeUsers []*common.Account - transactions []balanceTestTransaction -} - -type balanceTestTransaction struct { - source, destination *common.Account - quantity uint64 - - intentID string - intentState intent.State - actionState action.State - - transactionState transaction.Confirmation + owner *common.Account + tokenAccount *common.Account } func setupBalanceTestEnv(t *testing.T) (env balanceTestEnv) { @@ -708,77 +147,49 @@ func setupBalanceTestEnv(t *testing.T) (env balanceTestEnv) { return env } -func setupBalanceTestData(t *testing.T, env balanceTestEnv, data *balanceTestData) { - for _, owner := range data.codeUsers { - timelockAccounts, err := owner.GetTimelockAccounts(data.vmConfig) - require.NoError(t, err) - timelockRecord := timelockAccounts.ToDBRecord() - timelockRecord.VaultState = timelock_token_v1.StateLocked - timelockRecord.Block += 1 - require.NoError(t, env.data.SaveTimelock(env.ctx, timelockRecord)) - - accountInfoRecord := &account.Record{ - OwnerAccount: owner.PublicKey().ToBase58(), - AuthorityAccount: owner.PublicKey().ToBase58(), - TokenAccount: timelockRecord.VaultAddress, - MintAccount: data.vmConfig.Mint.PublicKey().ToBase58(), - AccountType: commonpb.AccountType_PRIMARY, - } - require.NoError(t, env.data.CreateAccountInfo(env.ctx, accountInfoRecord)) - } +// newBalanceTestAccount creates a locked timelock account, with an account +// info record but no ledger record. +func newBalanceTestAccount(t *testing.T, env balanceTestEnv) *balanceTestAccount { + return newBalanceTestAccountForOwner(t, env, testutil.NewRandomAccount(t), testutil.NewRandomVmConfig(t, true)) +} + +// newBalanceTestAccountForOwner is like newBalanceTestAccount, for an owner +// that holds accounts across several mints. +func newBalanceTestAccountForOwner(t *testing.T, env balanceTestEnv, owner *common.Account, vmConfig *common.VmConfig) *balanceTestAccount { + timelockAccounts, err := owner.GetTimelockAccounts(vmConfig) + require.NoError(t, err) + timelockRecord := timelockAccounts.ToDBRecord() + timelockRecord.VaultState = timelock_token_v1.StateLocked + timelockRecord.Block += 1 + require.NoError(t, env.data.SaveTimelock(env.ctx, timelockRecord)) + + require.NoError(t, env.data.CreateAccountInfo(env.ctx, &account.Record{ + OwnerAccount: owner.PublicKey().ToBase58(), + AuthorityAccount: owner.PublicKey().ToBase58(), + TokenAccount: timelockRecord.VaultAddress, + MintAccount: vmConfig.Mint.PublicKey().ToBase58(), + AccountType: commonpb.AccountType_PRIMARY, + })) + + tokenAccount, err := common.NewAccountFromPublicKeyString(timelockRecord.VaultAddress) + require.NoError(t, err) - for i, txn := range data.transactions { - // Setup the intent record with an equivalent action record - if len(txn.intentID) > 0 { - intentRecord := &intent.Record{ - IntentId: txn.intentID, - IntentType: intent.SendPublicPayment, - MintAccount: data.vmConfig.Mint.PublicKey().ToBase58(), - InitiatorOwnerAccount: "owner", - SendPublicPaymentMetadata: &intent.SendPublicPaymentMetadata{ - DestinationOwnerAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), - DestinationTokenAccount: txn.destination.PublicKey().ToBase58(), - Quantity: txn.quantity, - - ExchangeCurrency: currency_lib.USD, - ExchangeRate: 1.0, - NativeAmount: 1.0, - UsdMarketValue: 1.0, - }, - State: txn.intentState, - CreatedAt: time.Now(), - } - require.NoError(t, env.data.SaveIntent(env.ctx, intentRecord)) - - actionRecord := &action.Record{ - Intent: txn.intentID, - IntentType: intent.SendPublicPayment, - - ActionId: 0, - ActionType: action.NoPrivacyTransfer, - - Source: txn.source.PublicKey().ToBase58(), - Destination: &intentRecord.SendPublicPaymentMetadata.DestinationTokenAccount, - Quantity: &intentRecord.SendPublicPaymentMetadata.Quantity, - - State: txn.actionState, - } - require.NoError(t, env.data.PutAllActions(env.ctx, actionRecord)) - } - - // There's no intent, so we have an external deposit - if len(txn.intentID) == 0 && txn.transactionState != transaction.ConfirmationUnknown { - depositRecord := &deposit.Record{ - Signature: fmt.Sprintf("txn%d", i), - Destination: txn.destination.PublicKey().ToBase58(), - Amount: txn.quantity, - - Slot: 12345, - ConfirmationState: txn.transactionState, - - CreatedAt: time.Now(), - } - require.NoError(t, env.data.SaveExternalDeposit(env.ctx, depositRecord)) - } + return &balanceTestAccount{ + vmConfig: vmConfig, + owner: owner, + tokenAccount: tokenAccount, } } + +func (a *balanceTestAccount) mint() string { + return a.vmConfig.Mint.PublicKey().ToBase58() +} + +// saveBalanceTestRecord creates the account's ledger record, filling in the +// identifying fields from the account. +func saveBalanceTestRecord(t *testing.T, env balanceTestEnv, testAccount *balanceTestAccount, record *balance.Record) { + record.TokenAccount = testAccount.tokenAccount.PublicKey().ToBase58() + record.OwnerAccount = testAccount.owner.PublicKey().ToBase58() + record.MintAccount = testAccount.vmConfig.Mint.PublicKey().ToBase58() + require.NoError(t, env.data.CreateBalance(env.ctx, record)) +} diff --git a/ocp/balance/config.go b/ocp/balance/config.go deleted file mode 100644 index 6925291..0000000 --- a/ocp/balance/config.go +++ /dev/null @@ -1,25 +0,0 @@ -package balance - -import ( - "github.com/code-payments/ocp-server/config" - "github.com/code-payments/ocp-server/config/env" -) - -const ( - // EnableLedgerReadsConfigEnvName gates whether balance calculators read - // from the new ocp__core_balance ledger. When disabled, calculators use - // the legacy strategies exclusively. - EnableLedgerReadsConfigEnvName = "BALANCE_ENABLE_LEDGER_READS" - - // EnableLedgerWritesConfigEnvName gates whether ApplyDeltasInTx writes - // to the ledger at all. When disabled, it is a no-op. - EnableLedgerWritesConfigEnvName = "BALANCE_ENABLE_LEDGER_WRITES" - - defaultEnableLedgerReads = true - defaultEnableLedgerWrites = true -) - -var ( - enableLedgerReads config.Bool = env.NewBoolConfig(EnableLedgerReadsConfigEnvName, defaultEnableLedgerReads) - enableLedgerWrites config.Bool = env.NewBoolConfig(EnableLedgerWritesConfigEnvName, defaultEnableLedgerWrites) -) diff --git a/ocp/balance/ledger.go b/ocp/balance/ledger.go index 4ad81c5..84eb7cc 100644 --- a/ocp/balance/ledger.go +++ b/ocp/balance/ledger.go @@ -14,25 +14,10 @@ import ( // ledger doesn't track. var ErrUntrackedAccount = errors.New("account is not tracked by the balance ledger") -// LedgerReadsEnabled reports whether backfilled ledger records are the -// authoritative source for balance reads. -func LedgerReadsEnabled(ctx context.Context) bool { - return enableLedgerReads.Get(ctx) -} - -// LedgerWritesEnabled reports whether the ledger is being written to. -// Callers use it to skip building deltas entirely when writes are disabled, -// since builders reject flows the ledger doesn't support. -func LedgerWritesEnabled(ctx context.Context) bool { - return enableLedgerWrites.Get(ctx) -} - // ApplyDeltasInTx applies balance deltas to the ledger. It must be called // within the DB transaction that commits the records the deltas are derived // from, so the ledger can never disagree with them. // -// It is a no-op while ledger writes are disabled. -// // The ledger only tracks timelock accounts. Credits to any other account, // like an external wallet or the fee collector, are dropped, since delta // builders don't know which destinations OCP manages. Outgoing deltas from @@ -44,15 +29,14 @@ func LedgerWritesEnabled(ctx context.Context) bool { // Credits are still applied, since an unlocked record is excluded from every // read and turning one away only blocks the flow recording it. // -// Any timelock account in the delta set that has no ledger record yet lazily -// gets one that is not backfilled, so accounts that predate the ledger start -// accumulating deltas on first touch regardless of direction. +// A timelock account with no ledger record at all is balance.ErrRecordNotFound +// in either direction, since every one gets a record when it's opened. // // Store predicate failures (balance.ErrInsufficientBalance, // balance.ErrBalanceChanged, balance.ErrAccountClosed, // balance.ErrAccountUnlocked) are returned as is for the caller to map. func ApplyDeltasInTx(ctx context.Context, data ocp_data.Provider, deltas ...*balance.Delta) error { - if !enableLedgerWrites.Get(ctx) || len(deltas) == 0 { + if len(deltas) == 0 { return nil } @@ -84,13 +68,12 @@ func ApplyDeltasInTx(ctx context.Context, data ocp_data.Provider, deltas ...*bal // CreateRecordInTx creates the ledger record for a newly opened account. It // must be called within the DB transaction that creates the account info -// record. A new account has no history, so its record is created backfilled -// at zero and predicates are enforced from the start. +// record, so every timelock account has a record from the moment it exists. // -// It is a no-op while ledger writes are disabled, and for accounts that -// aren't timelock accounts, which the ledger doesn't track. +// It is a no-op for accounts that aren't timelock accounts, which the ledger +// doesn't track. func CreateRecordInTx(ctx context.Context, data ocp_data.Provider, accountInfoRecord *account.Record) error { - if !enableLedgerWrites.Get(ctx) || !accountInfoRecord.IsTimelock() { + if !accountInfoRecord.IsTimelock() { return nil } @@ -100,7 +83,6 @@ func CreateRecordInTx(ctx context.Context, data ocp_data.Provider, accountInfoRe MintAccount: accountInfoRecord.MintAccount, IsOpen: true, IsLocked: true, - IsBackfilled: true, }) if errors.Is(err, balance.ErrRecordExists) { return nil @@ -108,9 +90,11 @@ func CreateRecordInTx(ctx context.Context, data ocp_data.Provider, accountInfoRe return err } -// resolveRecords reports which accounts in the delta set the ledger tracks, -// creating a non-backfilled record for every timelock account that doesn't -// have one yet. +// resolveRecords reports which accounts in the delta set the ledger tracks. A +// timelock account without a record is a broken invariant rather than an +// untracked account, since CreateRecordInTx gives every one a record when it's +// opened, so it fails with balance.ErrRecordNotFound instead of being seeded +// with a balance that has no relationship to the account's history. func resolveRecords(ctx context.Context, data ocp_data.Provider, deltas []*balance.Delta) (map[string]bool, error) { tracked := make(map[string]bool) var tokenAccounts []string @@ -143,18 +127,7 @@ func resolveRecords(ctx context.Context, data ocp_data.Provider, deltas []*balan continue } - err = data.CreateBalance(ctx, &balance.Record{ - TokenAccount: accountInfoRecord.TokenAccount, - OwnerAccount: accountInfoRecord.OwnerAccount, - MintAccount: accountInfoRecord.MintAccount, - IsOpen: true, - IsLocked: true, - IsBackfilled: false, - }) - if err != nil && !errors.Is(err, balance.ErrRecordExists) { - return nil, err - } - tracked[tokenAccount] = true + return nil, fmt.Errorf("%w: %s", balance.ErrRecordNotFound, tokenAccount) } return tracked, nil } diff --git a/ocp/balance/ledger_test.go b/ocp/balance/ledger_test.go index 5bf6caa..a1eeefe 100644 --- a/ocp/balance/ledger_test.go +++ b/ocp/balance/ledger_test.go @@ -9,62 +9,42 @@ import ( commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" - "github.com/code-payments/ocp-server/config/memory" - "github.com/code-payments/ocp-server/config/wrapper" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/account" "github.com/code-payments/ocp-server/ocp/data/balance" "github.com/code-payments/ocp-server/testutil" ) -func TestApplyDeltasInTx_WritesDisabled(t *testing.T) { +func TestApplyDeltasInTx_TrackedAccounts(t *testing.T) { ctx := context.Background() data := ocp_data.NewTestDataProvider() - disableLedgerWritesForTest(t) - source := newLedgerTestAccount(t, ctx, data, commonpb.AccountType_PRIMARY) - - require.NoError(t, ApplyDeltasInTx(ctx, data, &balance.Delta{ - TokenAccount: source, - Kind: balance.DeltaDebit, - Quarks: 100, - })) - - _, err := data.GetBalance(ctx, source) - assert.Equal(t, balance.ErrRecordNotFound, err) -} - -func TestApplyDeltasInTx_SeedsTimelockAccounts(t *testing.T) { - ctx := context.Background() - data := ocp_data.NewTestDataProvider() - enableLedgerWritesForTest(t) - - source := newLedgerTestAccount(t, ctx, data, commonpb.AccountType_PRIMARY) - destination := newLedgerTestAccount(t, ctx, data, commonpb.AccountType_REMOTE_SEND_GIFT_CARD) + source := newLedgerTestAccountInfo(t, ctx, data, commonpb.AccountType_PRIMARY) + destination := newLedgerTestAccountInfo(t, ctx, data, commonpb.AccountType_REMOTE_SEND_GIFT_CARD) swap := newLedgerTestAccount(t, ctx, data, commonpb.AccountType_SWAP) external := testutil.NewRandomAccount(t).PublicKey().ToBase58() + require.NoError(t, CreateRecordInTx(ctx, data, source)) + require.NoError(t, CreateRecordInTx(ctx, data, destination)) + require.NoError(t, ApplyDeltasInTx(ctx, data, &balance.Delta{TokenAccount: source.TokenAccount, Kind: balance.DeltaCredit, Quarks: 100, UsdCostBasis: 1_000_000})) + require.NoError(t, ApplyDeltasInTx(ctx, data, - &balance.Delta{TokenAccount: source, Kind: balance.DeltaDebit, Quarks: 100, UsdCostBasis: 1_000_000}, - &balance.Delta{TokenAccount: destination, Kind: balance.DeltaCredit, Quarks: 60, UsdCostBasis: 600_000}, + &balance.Delta{TokenAccount: source.TokenAccount, Kind: balance.DeltaDebit, Quarks: 100, UsdCostBasis: 1_000_000}, + &balance.Delta{TokenAccount: destination.TokenAccount, Kind: balance.DeltaCredit, Quarks: 60, UsdCostBasis: 600_000}, &balance.Delta{TokenAccount: swap, Kind: balance.DeltaCredit, Quarks: 20, UsdCostBasis: 200_000}, &balance.Delta{TokenAccount: external, Kind: balance.DeltaCredit, Quarks: 20, UsdCostBasis: 200_000}, )) - // Existing accounts get a non-backfilled row that accumulates freely, - // including a negative balance for a source that predates the ledger - record, err := data.GetBalance(ctx, source) + record, err := data.GetBalance(ctx, source.TokenAccount) require.NoError(t, err) - assert.EqualValues(t, -100, record.Quarks) - assert.EqualValues(t, -1_000_000, record.UsdCostBasis) - assert.False(t, record.IsBackfilled) + assert.EqualValues(t, 0, record.Quarks) + assert.EqualValues(t, 0, record.UsdCostBasis) assert.True(t, record.IsOpen) - record, err = data.GetBalance(ctx, destination) + record, err = data.GetBalance(ctx, destination.TokenAccount) require.NoError(t, err) assert.EqualValues(t, 60, record.Quarks) assert.EqualValues(t, 600_000, record.UsdCostBasis) - assert.False(t, record.IsBackfilled) // Credits to accounts OCP doesn't hold a timelock for are dropped, and // those accounts never get a row @@ -73,24 +53,29 @@ func TestApplyDeltasInTx_SeedsTimelockAccounts(t *testing.T) { _, err = data.GetBalance(ctx, external) assert.Equal(t, balance.ErrRecordNotFound, err) - // Once backfilled, predicates are enforced - require.NoError(t, data.BackfillBalance(ctx, source, func(context.Context) (*balance.BackfillResult, error) { - return &balance.BackfillResult{Quarks: 400, UsdCostBasis: 4_000_000, IsOpen: true, IsLocked: true}, nil - })) - err = ApplyDeltasInTx(ctx, data, &balance.Delta{TokenAccount: source, Kind: balance.DeltaDebit, Quarks: 401}) + // Predicates are enforced against every record + err = ApplyDeltasInTx(ctx, data, &balance.Delta{TokenAccount: destination.TokenAccount, Kind: balance.DeltaDebit, Quarks: 61}) assert.Equal(t, balance.ErrInsufficientBalance, err) - require.NoError(t, ApplyDeltasInTx(ctx, data, &balance.Delta{TokenAccount: source, Kind: balance.DeltaDebit, Quarks: 400, UsdCostBasis: 4_000_000})) +} - record, err = data.GetBalance(ctx, source) - require.NoError(t, err) - assert.EqualValues(t, 0, record.Quarks) - assert.True(t, record.IsBackfilled) +func TestApplyDeltasInTx_MissingRecord(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + // A timelock account with no record is a broken invariant in either + // direction, not an account the ledger doesn't track + tokenAccount := newLedgerTestAccount(t, ctx, data, commonpb.AccountType_PRIMARY) + for _, delta := range []*balance.Delta{ + {TokenAccount: tokenAccount, Kind: balance.DeltaCredit, Quarks: 1}, + {TokenAccount: tokenAccount, Kind: balance.DeltaDebit, Quarks: 1}, + } { + assert.ErrorIs(t, ApplyDeltasInTx(ctx, data, delta), balance.ErrRecordNotFound) + } } func TestApplyDeltasInTx_UnknownSource(t *testing.T) { ctx := context.Background() data := ocp_data.NewTestDataProvider() - enableLedgerWritesForTest(t) external := testutil.NewRandomAccount(t).PublicKey().ToBase58() swap := newLedgerTestAccount(t, ctx, data, commonpb.AccountType_SWAP) @@ -103,7 +88,6 @@ func TestApplyDeltasInTx_UnknownSource(t *testing.T) { func TestApplyDeltasInTx_OnlyUntrackedCredits(t *testing.T) { ctx := context.Background() data := ocp_data.NewTestDataProvider() - enableLedgerWritesForTest(t) external := testutil.NewRandomAccount(t).PublicKey().ToBase58() require.NoError(t, ApplyDeltasInTx(ctx, data, &balance.Delta{TokenAccount: external, Kind: balance.DeltaCredit, Quarks: 1})) @@ -114,7 +98,6 @@ func TestApplyDeltasInTx_OnlyUntrackedCredits(t *testing.T) { func TestApplyDeltasInTx_UnlockedAccount(t *testing.T) { ctx := context.Background() data := ocp_data.NewTestDataProvider() - enableLedgerWritesForTest(t) unlocked := newLedgerTestAccountInfo(t, ctx, data, commonpb.AccountType_PRIMARY) require.NoError(t, CreateRecordInTx(ctx, data, unlocked)) @@ -139,7 +122,6 @@ func TestApplyDeltasInTx_UnlockedAccount(t *testing.T) { func TestApplyDeltasInTx_InvalidDelta(t *testing.T) { ctx := context.Background() data := ocp_data.NewTestDataProvider() - enableLedgerWritesForTest(t) source := newLedgerTestAccount(t, ctx, data, commonpb.AccountType_PRIMARY) assert.Error(t, ApplyDeltasInTx(ctx, data, &balance.Delta{TokenAccount: source, Kind: balance.DeltaDebit})) @@ -152,19 +134,9 @@ func TestCreateRecordInTx(t *testing.T) { ctx := context.Background() data := ocp_data.NewTestDataProvider() - // Disabled writes are a no-op primary := newLedgerTestAccountInfo(t, ctx, data, commonpb.AccountType_PRIMARY) - func() { - disableLedgerWritesForTest(t) - require.NoError(t, CreateRecordInTx(ctx, data, primary)) - _, err := data.GetBalance(ctx, primary.TokenAccount) - assert.Equal(t, balance.ErrRecordNotFound, err) - }() - - enableLedgerWritesForTest(t) - // A new timelock account starts backfilled at zero, so predicates are - // enforced immediately + // A new timelock account starts at zero, with predicates enforced require.NoError(t, CreateRecordInTx(ctx, data, primary)) record, err := data.GetBalance(ctx, primary.TokenAccount) require.NoError(t, err) @@ -174,7 +146,6 @@ func TestCreateRecordInTx(t *testing.T) { assert.EqualValues(t, 0, record.Quarks) assert.EqualValues(t, 0, record.UsdCostBasis) assert.True(t, record.IsOpen) - assert.True(t, record.IsBackfilled) err = ApplyDeltasInTx(ctx, data, &balance.Delta{TokenAccount: primary.TokenAccount, Kind: balance.DeltaDebit, Quarks: 1}) assert.Equal(t, balance.ErrInsufficientBalance, err) @@ -213,19 +184,3 @@ func newLedgerTestAccountInfo(t *testing.T, ctx context.Context, data ocp_data.P require.NoError(t, data.CreateAccountInfo(ctx, record)) return record } - -func disableLedgerWritesForTest(t *testing.T) { - previous := enableLedgerWrites - enableLedgerWrites = wrapper.NewBoolConfig(memory.NewConfig(false), defaultEnableLedgerWrites) - t.Cleanup(func() { - enableLedgerWrites = previous - }) -} - -func enableLedgerWritesForTest(t *testing.T) { - previous := enableLedgerWrites - enableLedgerWrites = wrapper.NewBoolConfig(memory.NewConfig(true), defaultEnableLedgerWrites) - t.Cleanup(func() { - enableLedgerWrites = previous - }) -} diff --git a/ocp/data/balance/memory/store.go b/ocp/data/balance/memory/store.go index 179e20c..c7c5794 100644 --- a/ocp/data/balance/memory/store.go +++ b/ocp/data/balance/memory/store.go @@ -101,7 +101,7 @@ func (s *store) GetAllByOwnerAndMint(_ context.Context, owner, mint string) ([]* } // GetAllLockedByMint implements balance.Store.GetAllLockedByMint -func (s *store) GetAllLockedByMint(_ context.Context, mint string, minQuarks int64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*balance.Record, error) { +func (s *store) GetAllLockedByMint(_ context.Context, mint string, minQuarks uint64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*balance.Record, error) { s.mu.Lock() defer s.mu.Unlock() @@ -136,13 +136,13 @@ func (s *store) GetAllLockedByMint(_ context.Context, mint string, minQuarks int } // CountLockedByMint implements balance.Store.CountLockedByMint -func (s *store) CountLockedByMint(_ context.Context, mint string, minQuarks int64) (uint64, error) { +func (s *store) CountLockedByMint(_ context.Context, mint string, minQuarks uint64) (uint64, error) { s.mu.Lock() defer s.mu.Unlock() var res uint64 for _, item := range s.balanceRecordsByTokenAccount { - if item.MintAccount == mint && item.Quarks >= minQuarks && item.IsLocked && item.IsBackfilled { + if item.MintAccount == mint && item.Quarks >= minQuarks && item.IsLocked { res++ } } @@ -206,8 +206,6 @@ func (s *store) ApplyDeltas(_ context.Context, deltas ...*balance.Delta) error { } func applyDelta(item *balance.Record, delta *balance.Delta) error { - enforce := item.IsBackfilled - // A credit doesn't require the vault to be locked, since an unlocked // record is excluded from every read anyway and turning the credit away // only blocks the flow recording it. A cost basis adjustment carries no @@ -215,50 +213,43 @@ func applyDelta(item *balance.Record, delta *balance.Delta) error { switch delta.Kind { case balance.DeltaCredit, balance.DeltaAdjustUsdCostBasis: default: - if enforce && !item.IsLocked { + if !item.IsLocked { return balance.ErrAccountUnlocked } } switch delta.Kind { case balance.DeltaCredit: - if enforce && !item.IsOpen { + if !item.IsOpen { return balance.ErrAccountClosed } - item.Quarks += int64(delta.Quarks) + item.Quarks += delta.Quarks item.UsdCostBasis += delta.UsdCostBasis case balance.DeltaDebit: - if enforce && !item.IsOpen { + if !item.IsOpen { return balance.ErrAccountClosed } - if enforce && item.Quarks < int64(delta.Quarks) { + if item.Quarks < delta.Quarks { return balance.ErrInsufficientBalance } - item.Quarks -= int64(delta.Quarks) + item.Quarks -= delta.Quarks item.UsdCostBasis -= delta.UsdCostBasis case balance.DeltaDrain: - if enforce { - if !item.IsOpen { - return balance.ErrAccountClosed - } - if item.Quarks != int64(delta.Quarks) { - return balance.ErrBalanceChanged - } - item.Quarks = 0 - item.UsdCostBasis = 0 - } else { - item.Quarks -= int64(delta.Quarks) - item.UsdCostBasis -= delta.UsdCostBasis + if !item.IsOpen { + return balance.ErrAccountClosed } + if item.Quarks != delta.Quarks { + return balance.ErrBalanceChanged + } + item.Quarks = 0 + item.UsdCostBasis = 0 item.IsOpen = false case balance.DeltaClose: - if enforce { - if !item.IsOpen { - return balance.ErrAccountClosed - } - if item.Quarks != 0 { - return balance.ErrBalanceChanged - } + if !item.IsOpen { + return balance.ErrAccountClosed + } + if item.Quarks != 0 { + return balance.ErrBalanceChanged } item.IsOpen = false case balance.DeltaAdjustUsdCostBasis: @@ -267,44 +258,6 @@ func applyDelta(item *balance.Record, delta *balance.Delta) error { return nil } -// Backfill implements balance.Store.Backfill -// -// Note: The lock is released while fn runs, since fn reads from other stores -// sharing the provider. Unlike the DB store, this doesn't block concurrent -// deltas, which tests don't exercise against a backfill. -func (s *store) Backfill(ctx context.Context, tokenAccount string, fn balance.BackfillFunc) error { - s.mu.Lock() - item, ok := s.balanceRecordsByTokenAccount[tokenAccount] - if !ok { - s.mu.Unlock() - return balance.ErrRecordNotFound - } - if item.IsBackfilled { - s.mu.Unlock() - return balance.ErrAlreadyBackfilled - } - s.mu.Unlock() - - result, err := fn(ctx) - if err != nil { - return err - } - if result.Quarks < 0 { - return balance.ErrNegativeBalance - } - - s.mu.Lock() - defer s.mu.Unlock() - - item.Quarks = result.Quarks - item.UsdCostBasis = result.UsdCostBasis - item.IsOpen = result.IsOpen - item.IsLocked = result.IsLocked - item.IsBackfilled = true - item.UpdatedAt = time.Now() - return nil -} - func (s *store) filter(fn func(*balance.Record) bool) ([]*balance.Record, error) { var res []*balance.Record for _, item := range s.balanceRecords { diff --git a/ocp/data/balance/postgres/model.go b/ocp/data/balance/postgres/model.go index 9a29133..eea77b8 100644 --- a/ocp/data/balance/postgres/model.go +++ b/ocp/data/balance/postgres/model.go @@ -17,7 +17,10 @@ const ( tableName = "ocp__core_balance" externalCheckpointTableName = "ocp__core_externalbalancecheckpoint" - allColumns = "id, token_account, owner_account, mint_account, quarks, usd_cost_basis, is_open, is_locked, is_backfilled, updated_at" + // Note: is_backfilled is deliberately absent. The column still exists in + // prod, where every row is backfilled, so it is written as TRUE on insert + // and never read. + allColumns = "id, token_account, owner_account, mint_account, quarks, usd_cost_basis, is_open, is_locked, updated_at" ) type model struct { @@ -27,12 +30,11 @@ type model struct { OwnerAccount string `db:"owner_account"` MintAccount string `db:"mint_account"` - Quarks int64 `db:"quarks"` - UsdCostBasis int64 `db:"usd_cost_basis"` + Quarks uint64 `db:"quarks"` + UsdCostBasis int64 `db:"usd_cost_basis"` - IsOpen bool `db:"is_open"` - IsLocked bool `db:"is_locked"` - IsBackfilled bool `db:"is_backfilled"` + IsOpen bool `db:"is_open"` + IsLocked bool `db:"is_locked"` UpdatedAt time.Time `db:"updated_at"` } @@ -50,9 +52,8 @@ func toModel(obj *balance.Record) (*model, error) { Quarks: obj.Quarks, UsdCostBasis: obj.UsdCostBasis, - IsOpen: obj.IsOpen, - IsLocked: obj.IsLocked, - IsBackfilled: obj.IsBackfilled, + IsOpen: obj.IsOpen, + IsLocked: obj.IsLocked, UpdatedAt: obj.UpdatedAt, }, nil @@ -69,9 +70,8 @@ func fromModel(obj *model) *balance.Record { Quarks: obj.Quarks, UsdCostBasis: obj.UsdCostBasis, - IsOpen: obj.IsOpen, - IsLocked: obj.IsLocked, - IsBackfilled: obj.IsBackfilled, + IsOpen: obj.IsOpen, + IsLocked: obj.IsLocked, UpdatedAt: obj.UpdatedAt, } @@ -81,7 +81,7 @@ func (m *model) dbCreate(ctx context.Context, db *sqlx.DB) error { return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { query := `INSERT INTO ` + tableName + ` (token_account, owner_account, mint_account, quarks, usd_cost_basis, is_open, is_locked, is_backfilled, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE, $8) RETURNING ` + allColumns m.UpdatedAt = time.Now() @@ -96,7 +96,6 @@ func (m *model) dbCreate(ctx context.Context, db *sqlx.DB) error { m.UsdCostBasis, m.IsOpen, m.IsLocked, - m.IsBackfilled, m.UpdatedAt.UTC(), ).StructScan(m) @@ -161,7 +160,7 @@ func dbGetAllByOwner(ctx context.Context, db *sqlx.DB, owner string, mint *strin return res, nil } -func dbGetAllLockedByMint(ctx context.Context, db *sqlx.DB, mint string, minQuarks int64, cursor q.Cursor, limit uint64, direction q.Ordering) ([]*model, error) { +func dbGetAllLockedByMint(ctx context.Context, db *sqlx.DB, mint string, minQuarks uint64, cursor q.Cursor, limit uint64, direction q.Ordering) ([]*model, error) { res := []*model{} query := `SELECT ` + allColumns + ` FROM ` + tableName + ` @@ -180,10 +179,10 @@ func dbGetAllLockedByMint(ctx context.Context, db *sqlx.DB, mint string, minQuar return res, nil } -func dbCountLockedByMint(ctx context.Context, db *sqlx.DB, mint string, minQuarks int64) (uint64, error) { +func dbCountLockedByMint(ctx context.Context, db *sqlx.DB, mint string, minQuarks uint64) (uint64, error) { var res uint64 query := `SELECT COUNT(*) FROM ` + tableName + ` - WHERE mint_account = $1 AND quarks >= $2 AND is_locked AND is_backfilled` + WHERE mint_account = $1 AND quarks >= $2 AND is_locked` err := db.GetContext(ctx, &res, query, mint, minQuarks) if err != nil { return 0, err @@ -213,7 +212,7 @@ func dbMarkAsUnlocked(ctx context.Context, db *sqlx.DB, tokenAccount string) err // dbApplyDeltas applies every delta in a single transaction. Each delta is one // conditional UPDATE, so its predicate is evaluated against the row after the -// row lock is acquired. Predicates only apply to backfilled rows. +// row lock is acquired. func dbApplyDeltas(ctx context.Context, db *sqlx.DB, deltas []*balance.Delta) error { return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { for _, delta := range deltas { @@ -223,25 +222,22 @@ func dbApplyDeltas(ctx context.Context, db *sqlx.DB, deltas []*balance.Delta) er case balance.DeltaCredit: query = `UPDATE ` + tableName + ` SET quarks = quarks + $2, usd_cost_basis = usd_cost_basis + $3, updated_at = $4 - WHERE token_account = $1 AND (NOT is_backfilled OR is_open)` + WHERE token_account = $1 AND is_open` args = []any{delta.TokenAccount, int64(delta.Quarks), delta.UsdCostBasis, time.Now().UTC()} case balance.DeltaDebit: query = `UPDATE ` + tableName + ` SET quarks = quarks - $2, usd_cost_basis = usd_cost_basis - $3, updated_at = $4 - WHERE token_account = $1 AND (NOT is_backfilled OR (is_open AND is_locked AND quarks >= $2))` + WHERE token_account = $1 AND is_open AND is_locked AND quarks >= $2` args = []any{delta.TokenAccount, int64(delta.Quarks), delta.UsdCostBasis, time.Now().UTC()} case balance.DeltaDrain: query = `UPDATE ` + tableName + ` - SET quarks = CASE WHEN is_backfilled THEN 0 ELSE quarks - $2 END, - usd_cost_basis = CASE WHEN is_backfilled THEN 0 ELSE usd_cost_basis - $3 END, - is_open = FALSE, - updated_at = $4 - WHERE token_account = $1 AND (NOT is_backfilled OR (is_open AND is_locked AND quarks = $2))` - args = []any{delta.TokenAccount, int64(delta.Quarks), delta.UsdCostBasis, time.Now().UTC()} + SET quarks = 0, usd_cost_basis = 0, is_open = FALSE, updated_at = $3 + WHERE token_account = $1 AND is_open AND is_locked AND quarks = $2` + args = []any{delta.TokenAccount, int64(delta.Quarks), time.Now().UTC()} case balance.DeltaClose: query = `UPDATE ` + tableName + ` SET is_open = FALSE, updated_at = $2 - WHERE token_account = $1 AND (NOT is_backfilled OR (is_open AND is_locked AND quarks = 0))` + WHERE token_account = $1 AND is_open AND is_locked AND quarks = 0` args = []any{delta.TokenAccount, time.Now().UTC()} case balance.DeltaAdjustUsdCostBasis: // No predicate: no quarks move, so nothing the other kinds @@ -305,47 +301,6 @@ func classifyFailedDelta(delta *balance.Delta, current *balance.Record) error { return fmt.Errorf("unsupported delta kind: %s", delta.Kind) } -func dbBackfill(ctx context.Context, db *sqlx.DB, tokenAccount string, fn balance.BackfillFunc) error { - return executeTxWithinCtxOrJoin(ctx, db, func(ctx context.Context) error { - return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { - var current model - err := tx.GetContext(ctx, ¤t, `SELECT `+allColumns+` FROM `+tableName+` WHERE token_account = $1 FOR UPDATE`, tokenAccount) - if err != nil { - return pgutil.CheckNoRows(err, balance.ErrRecordNotFound) - } - if current.IsBackfilled { - return balance.ErrAlreadyBackfilled - } - - // The row lock is held across fn, so it observes every committed - // write to the account and blocks every in-flight one. - result, err := fn(ctx) - if err != nil { - return err - } - if result.Quarks < 0 { - return balance.ErrNegativeBalance - } - - query := `UPDATE ` + tableName + ` - SET quarks = $2, usd_cost_basis = $3, is_open = $4, is_locked = $5, is_backfilled = TRUE, updated_at = $6 - WHERE token_account = $1` - _, err = tx.ExecContext(ctx, query, tokenAccount, result.Quarks, result.UsdCostBasis, result.IsOpen, result.IsLocked, time.Now().UTC()) - return err - }) - }) -} - -// executeTxWithinCtxOrJoin runs fn with a context carrying a DB transaction, -// starting one if the context doesn't already have one. -func executeTxWithinCtxOrJoin(ctx context.Context, db *sqlx.DB, fn func(ctx context.Context) error) error { - err := pgutil.ExecuteTxWithinCtx(ctx, db, sql.LevelDefault, fn) - if err == pgutil.ErrAlreadyInTx { - return fn(ctx) - } - return err -} - type externalCheckpointModel struct { Id sql.NullInt64 `db:"id"` diff --git a/ocp/data/balance/postgres/store.go b/ocp/data/balance/postgres/store.go index 67d5b96..00bbf3d 100644 --- a/ocp/data/balance/postgres/store.go +++ b/ocp/data/balance/postgres/store.go @@ -78,7 +78,7 @@ func (s *store) GetAllByOwnerAndMint(ctx context.Context, owner, mint string) ([ } // GetAllLockedByMint implements balance.Store.GetAllLockedByMint -func (s *store) GetAllLockedByMint(ctx context.Context, mint string, minQuarks int64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*balance.Record, error) { +func (s *store) GetAllLockedByMint(ctx context.Context, mint string, minQuarks uint64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*balance.Record, error) { models, err := dbGetAllLockedByMint(ctx, s.db, mint, minQuarks, cursor, limit, direction) if err != nil { return nil, err @@ -87,7 +87,7 @@ func (s *store) GetAllLockedByMint(ctx context.Context, mint string, minQuarks i } // CountLockedByMint implements balance.Store.CountLockedByMint -func (s *store) CountLockedByMint(ctx context.Context, mint string, minQuarks int64) (uint64, error) { +func (s *store) CountLockedByMint(ctx context.Context, mint string, minQuarks uint64) (uint64, error) { return dbCountLockedByMint(ctx, s.db, mint, minQuarks) } @@ -107,11 +107,6 @@ func (s *store) ApplyDeltas(ctx context.Context, deltas ...*balance.Delta) error return dbApplyDeltas(ctx, s.db, balance.MergeDeltas(deltas)) } -// Backfill implements balance.Store.Backfill -func (s *store) Backfill(ctx context.Context, tokenAccount string, fn balance.BackfillFunc) error { - return dbBackfill(ctx, s.db, tokenAccount, fn) -} - func fromModels(models []*model) []*balance.Record { res := make([]*balance.Record, len(models)) for i, model := range models { diff --git a/ocp/data/balance/record.go b/ocp/data/balance/record.go index c82f580..a471344 100644 --- a/ocp/data/balance/record.go +++ b/ocp/data/balance/record.go @@ -33,10 +33,7 @@ type Record struct { OwnerAccount string MintAccount string - // Quarks is signed because a record that has not been backfilled only - // accumulates deltas, which may temporarily net negative. Backfilled - // records are guaranteed to be non-negative. - Quarks int64 + Quarks uint64 // UsdCostBasis is the account's USD cost basis, in UsdQuarksPerUnit. // A cost basis may legitimately be negative. @@ -53,11 +50,6 @@ type Record struct { // one-way. IsLocked bool - // IsBackfilled indicates the record reflects the full history of the - // account. Until it does, deltas are recorded without enforcing any - // balance predicates. - IsBackfilled bool - UpdatedAt time.Time } @@ -74,10 +66,6 @@ func (r *Record) Validate() error { return errors.New("mint account is required") } - if r.IsBackfilled && r.Quarks < 0 { - return errors.New("backfilled quarks cannot be negative") - } - return nil } @@ -92,9 +80,8 @@ func (r *Record) Clone() Record { Quarks: r.Quarks, UsdCostBasis: r.UsdCostBasis, - IsOpen: r.IsOpen, - IsLocked: r.IsLocked, - IsBackfilled: r.IsBackfilled, + IsOpen: r.IsOpen, + IsLocked: r.IsLocked, UpdatedAt: r.UpdatedAt, } @@ -112,13 +99,11 @@ func (r *Record) CopyTo(dst *Record) { dst.IsOpen = r.IsOpen dst.IsLocked = r.IsLocked - dst.IsBackfilled = r.IsBackfilled dst.UpdatedAt = r.UpdatedAt } -// DeltaKind selects the predicate a Delta is applied under. Predicates are -// only enforced on backfilled records. +// DeltaKind selects the predicate a Delta is applied under. type DeltaKind uint8 const ( @@ -154,9 +139,9 @@ type Delta struct { // UsdCostBasis is added on credit and subtracted on debit. It is signed // so that a credit can also carry a downward reconciliation. Ignored for - // DeltaDrain and DeltaClose on backfilled records, where the basis is - // zeroed along with the balance. For DeltaAdjustUsdCostBasis it is the - // signed correction, added as is. + // DeltaDrain and DeltaClose, where the basis is zeroed along with the + // balance. For DeltaAdjustUsdCostBasis it is the signed correction, added + // as is. UsdCostBasis int64 } diff --git a/ocp/data/balance/store.go b/ocp/data/balance/store.go index b427b17..5b6a8db 100644 --- a/ocp/data/balance/store.go +++ b/ocp/data/balance/store.go @@ -18,13 +18,6 @@ var ( // balance than the one on record. ErrBalanceChanged = errors.New("balance is not the expected value") - ErrAlreadyBackfilled = errors.New("balance record is already backfilled") - - // ErrNegativeBalance is returned when a backfill computes a negative - // balance, which indicates inconsistent historical data that must be - // reviewed rather than recorded. - ErrNegativeBalance = errors.New("backfilled balance is negative") - ErrAccountClosed = errors.New("account open state is stale") // ErrAccountUnlocked is returned when a delta other than a credit targets @@ -38,26 +31,6 @@ var ( ErrStaleCheckpoint = errors.New("checkpoint is stale") ) -// BackfillResult is the full historical state of a token account. -type BackfillResult struct { - Quarks int64 - UsdCostBasis int64 - - // IsOpen is false for accounts that can no longer receive funds, such as - // claimed gift cards and distributed pools. - IsOpen bool - - // IsLocked is false for accounts whose timelock vault has unlocked, so - // the balance is the last managed state rather than a live value. - IsLocked bool -} - -// BackfillFunc computes the full historical state of a token account. It -// is called while the record is locked, with a context that is part of the -// same DB transaction, so any store reads made through it observe every -// committed change and block every in-flight one. -type BackfillFunc func(ctx context.Context) (*BackfillResult, error) - type Store interface { // Create creates a new balance record. // @@ -89,21 +62,16 @@ type Store interface { // making their balances stale. // // ErrRecordNotFound is returned if no records exist. - GetAllLockedByMint(ctx context.Context, mint string, minQuarks int64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*Record, error) + GetAllLockedByMint(ctx context.Context, mint string, minQuarks uint64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*Record, error) - // CountLockedByMint counts locked, backfilled records for a mint with at - // least minQuarks. Records that are not backfilled are excluded, since - // their balances are partial sums that can't be compared against a - // threshold. Unlocked records are excluded, since their balances are + // CountLockedByMint counts locked records for a mint with at least + // minQuarks. Unlocked records are excluded, since their balances are // stale. - CountLockedByMint(ctx context.Context, mint string, minQuarks int64) (uint64, error) + CountLockedByMint(ctx context.Context, mint string, minQuarks uint64) (uint64, error) // ApplyDeltas atomically applies a set of deltas. Either every delta is // applied or none are. Deltas are applied in SortDeltas order. // - // Predicates are enforced only on backfilled records; records that are - // not backfilled simply accumulate the change. - // // Every delta must target an account with a record, otherwise // ErrRecordNotFound is returned and nothing is applied. Callers are // responsible for not producing deltas for accounts the ledger doesn't @@ -127,17 +95,6 @@ type Store interface { // ErrRecordNotFound is returned if no record exists. MarkAsUnlocked(ctx context.Context, tokenAccount string) error - // Backfill locks a record that is not yet backfilled, calls fn to compute - // its full historical balance, and overwrites the record with the result, - // marking it as backfilled. Deltas recorded before the backfill are - // intentionally discarded, since fn observes them. - // - // ErrRecordNotFound is returned if no record exists. ErrAlreadyBackfilled - // is returned if the record is already backfilled, in which case fn is - // not called. ErrNegativeBalance is returned if fn computes a negative - // balance, leaving the record untouched. - Backfill(ctx context.Context, tokenAccount string, fn BackfillFunc) error - // SaveExternalCheckpoint saves an external balance at a checkpoint. // // ErrStaleCheckpoint is returned if the checkpoint is outdated diff --git a/ocp/data/balance/tests/tests.go b/ocp/data/balance/tests/tests.go index bb569dd..0ee5985 100644 --- a/ocp/data/balance/tests/tests.go +++ b/ocp/data/balance/tests/tests.go @@ -17,11 +17,9 @@ func RunTests(t *testing.T, s balance.Store, teardown func()) { for _, tf := range []func(t *testing.T, s balance.Store){ testRecordHappyPath, testGetAllLockedByMint, - testApplyDeltasBackfilled, - testApplyDeltasNotBackfilled, + testApplyDeltas, testApplyDeltasAtomicity, testApplyDeltasConcurrency, - testBackfill, testMarkAsUnlocked, testExternalCheckpointHappyPath, } { @@ -57,7 +55,6 @@ func testRecordHappyPath(t *testing.T, s balance.Store) { UsdCostBasis: 200, IsOpen: true, IsLocked: true, - IsBackfilled: true, } cloned := expected.Clone() @@ -105,14 +102,6 @@ func testRecordHappyPath(t *testing.T, s balance.Store) { _, err = s.GetAllByOwnerAndMint(ctx, "owner_2", "mint_2") assert.Equal(t, balance.ErrRecordNotFound, err) - - assert.Error(t, s.Create(ctx, &balance.Record{ - TokenAccount: "token_account_5", - OwnerAccount: "owner_1", - MintAccount: "mint_1", - Quarks: -1, - IsBackfilled: true, - })) }) } @@ -128,10 +117,9 @@ func testGetAllLockedByMint(t *testing.T, s balance.Store) { TokenAccount: "token_account_" + string(rune('a'+i)), OwnerAccount: "owner", MintAccount: "mint_1", - Quarks: int64(i * 10), + Quarks: uint64(i * 10), IsOpen: true, IsLocked: true, - IsBackfilled: true, })) } require.NoError(t, s.Create(ctx, &balance.Record{ @@ -141,7 +129,6 @@ func testGetAllLockedByMint(t *testing.T, s balance.Store) { Quarks: 1000, IsOpen: true, IsLocked: true, - IsBackfilled: true, })) records, err := s.GetAllLockedByMint(ctx, "mint_1", 0, query.EmptyCursor, 10, query.Ascending) @@ -186,7 +173,6 @@ func testGetAllLockedByMint(t *testing.T, s balance.Store) { MintAccount: "mint_1", Quarks: 1000, IsOpen: true, - IsBackfilled: true, })) records, err = s.GetAllLockedByMint(ctx, "mint_1", 0, query.EmptyCursor, 10, query.Ascending) require.NoError(t, err) @@ -195,18 +181,7 @@ func testGetAllLockedByMint(t *testing.T, s balance.Store) { _, err = s.GetAllLockedByMint(ctx, "mint_1", 0, query.ToCursor(5), 10, query.Ascending) assert.Equal(t, balance.ErrRecordNotFound, err) - // Counting by mint uses the same threshold semantics, but only over - // backfilled records - require.NoError(t, s.Create(ctx, &balance.Record{ - TokenAccount: "token_account_not_backfilled", - OwnerAccount: "owner", - MintAccount: "mint_1", - Quarks: 1000, - IsOpen: true, - IsLocked: true, - IsBackfilled: false, - })) - + // Counting by mint uses the same threshold semantics count, err := s.CountLockedByMint(ctx, "mint_1", 0) require.NoError(t, err) assert.EqualValues(t, 5, count) @@ -225,8 +200,8 @@ func testGetAllLockedByMint(t *testing.T, s balance.Store) { }) } -func testApplyDeltasBackfilled(t *testing.T, s balance.Store) { - t.Run("testApplyDeltasBackfilled", func(t *testing.T) { +func testApplyDeltas(t *testing.T, s balance.Store) { + t.Run("testApplyDeltas", func(t *testing.T) { ctx := context.Background() require.NoError(t, s.Create(ctx, &balance.Record{ @@ -235,7 +210,6 @@ func testApplyDeltasBackfilled(t *testing.T, s balance.Store) { MintAccount: "mint", IsOpen: true, IsLocked: true, - IsBackfilled: true, })) // Every kind of delta requires a record @@ -312,45 +286,12 @@ func testApplyDeltasBackfilled(t *testing.T, s balance.Store) { MintAccount: "mint", IsOpen: true, IsLocked: true, - IsBackfilled: true, })) require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_2", Kind: balance.DeltaClose})) assertBalance(t, s, "token_account_2", 0, 0, false) }) } -func testApplyDeltasNotBackfilled(t *testing.T, s balance.Store) { - t.Run("testApplyDeltasNotBackfilled", func(t *testing.T) { - ctx := context.Background() - - require.NoError(t, s.Create(ctx, &balance.Record{ - TokenAccount: "token_account_1", - OwnerAccount: "owner", - MintAccount: "mint", - IsOpen: true, - IsLocked: true, - })) - - // No predicates are enforced, and the balance can go negative - require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_1", Kind: balance.DeltaDebit, Quarks: 30, UsdCostBasis: 10})) - assertBalance(t, s, "token_account_1", -30, -10, true) - - require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_1", Kind: balance.DeltaCredit, Quarks: 100, UsdCostBasis: 50})) - assertBalance(t, s, "token_account_1", 70, 40, true) - - // A drain records the delta rather than zeroing, but still closes - require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_1", Kind: balance.DeltaDrain, Quarks: 100, UsdCostBasis: 60})) - assertBalance(t, s, "token_account_1", -30, -20, false) - - // Closed accounts still accumulate - require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_1", Kind: balance.DeltaCredit, Quarks: 5, UsdCostBasis: 5})) - assertBalance(t, s, "token_account_1", -25, -15, false) - - require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_1", Kind: balance.DeltaClose})) - assertBalance(t, s, "token_account_1", -25, -15, false) - }) -} - func testApplyDeltasAtomicity(t *testing.T, s balance.Store) { t.Run("testApplyDeltasAtomicity", func(t *testing.T) { ctx := context.Background() @@ -363,7 +304,6 @@ func testApplyDeltasAtomicity(t *testing.T, s balance.Store) { Quarks: 100, IsOpen: true, IsLocked: true, - IsBackfilled: true, })) } @@ -436,7 +376,6 @@ func testApplyDeltasConcurrency(t *testing.T, s balance.Store) { Quarks: initialBalance, IsOpen: true, IsLocked: true, - IsBackfilled: true, })) require.NoError(t, s.Create(ctx, &balance.Record{ TokenAccount: "receiver", @@ -444,7 +383,6 @@ func testApplyDeltasConcurrency(t *testing.T, s balance.Store) { MintAccount: "mint", IsOpen: true, IsLocked: true, - IsBackfilled: true, })) // Concurrent sends of 1 quark each: exactly initialBalance succeed, and @@ -524,80 +462,6 @@ func testApplyDeltasConcurrency(t *testing.T, s balance.Store) { }) } -func testBackfill(t *testing.T, s balance.Store) { - t.Run("testBackfill", func(t *testing.T) { - ctx := context.Background() - - fn := func(quarks, usdCostBasis int64, isOpen bool) balance.BackfillFunc { - return func(ctx context.Context) (*balance.BackfillResult, error) { - return &balance.BackfillResult{Quarks: quarks, UsdCostBasis: usdCostBasis, IsOpen: isOpen, IsLocked: true}, nil - } - } - - assert.Equal(t, balance.ErrRecordNotFound, s.Backfill(ctx, "token_account_1", fn(1, 1, true))) - - require.NoError(t, s.Create(ctx, &balance.Record{ - TokenAccount: "token_account_1", - OwnerAccount: "owner", - MintAccount: "mint", - IsOpen: true, - IsLocked: true, - })) - - // Deltas recorded before the backfill are discarded by it - require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_1", Kind: balance.DeltaDebit, Quarks: 30, UsdCostBasis: 10})) - assertBalance(t, s, "token_account_1", -30, -10, true) - - // A failing computation leaves the record untouched - assert.Error(t, s.Backfill(ctx, "token_account_1", func(ctx context.Context) (*balance.BackfillResult, error) { - return nil, assert.AnError - })) - record, err := s.Get(ctx, "token_account_1") - require.NoError(t, err) - assert.False(t, record.IsBackfilled) - assert.EqualValues(t, -30, record.Quarks) - - // So does a negative computed balance - assert.Equal(t, balance.ErrNegativeBalance, s.Backfill(ctx, "token_account_1", fn(-1, 0, true))) - record, err = s.Get(ctx, "token_account_1") - require.NoError(t, err) - assert.False(t, record.IsBackfilled) - assert.EqualValues(t, -30, record.Quarks) - - require.NoError(t, s.Backfill(ctx, "token_account_1", fn(500, 250, true))) - record, err = s.Get(ctx, "token_account_1") - require.NoError(t, err) - assert.True(t, record.IsBackfilled) - assert.True(t, record.IsLocked) - assertBalance(t, s, "token_account_1", 500, 250, true) - - // Predicates are enforced from now on - assert.Equal(t, balance.ErrInsufficientBalance, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_1", Kind: balance.DeltaDebit, Quarks: 501})) - require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_1", Kind: balance.DeltaDebit, Quarks: 500})) - assertBalance(t, s, "token_account_1", 0, 250, true) - - called := false - assert.Equal(t, balance.ErrAlreadyBackfilled, s.Backfill(ctx, "token_account_1", func(ctx context.Context) (*balance.BackfillResult, error) { - called = true - return &balance.BackfillResult{}, nil - })) - assert.False(t, called) - assertBalance(t, s, "token_account_1", 0, 250, true) - - // A backfill can establish the account as closed, e.g. a claimed gift card - require.NoError(t, s.Create(ctx, &balance.Record{ - TokenAccount: "token_account_2", - OwnerAccount: "owner", - MintAccount: "mint", - IsOpen: true, - IsLocked: true, - })) - require.NoError(t, s.Backfill(ctx, "token_account_2", fn(0, 0, false))) - assertBalance(t, s, "token_account_2", 0, 0, false) - assert.Equal(t, balance.ErrAccountClosed, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_2", Kind: balance.DeltaCredit, Quarks: 1})) - }) -} - func testMarkAsUnlocked(t *testing.T, s balance.Store) { t.Run("testMarkAsUnlocked", func(t *testing.T) { ctx := context.Background() @@ -611,7 +475,6 @@ func testMarkAsUnlocked(t *testing.T, s balance.Store) { Quarks: 100, IsOpen: true, IsLocked: true, - IsBackfilled: true, })) require.NoError(t, s.MarkAsUnlocked(ctx, "token_account_1")) @@ -649,23 +512,10 @@ func testMarkAsUnlocked(t *testing.T, s balance.Store) { MintAccount: "mint", IsOpen: true, IsLocked: true, - IsBackfilled: true, })) require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_3", Kind: balance.DeltaClose})) require.NoError(t, s.MarkAsUnlocked(ctx, "token_account_3")) assert.Equal(t, balance.ErrAccountClosed, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_3", Kind: balance.DeltaCredit, Quarks: 1})) - - // A record that is not backfilled accumulates without predicates, - // unlocked included; the backfill computes the truth for it - require.NoError(t, s.Create(ctx, &balance.Record{ - TokenAccount: "token_account_2", - OwnerAccount: "owner", - MintAccount: "mint", - IsOpen: true, - IsLocked: false, - })) - require.NoError(t, s.ApplyDeltas(ctx, &balance.Delta{TokenAccount: "token_account_2", Kind: balance.DeltaCredit, Quarks: 5})) - assertBalance(t, s, "token_account_2", 5, 0, true) }) } @@ -723,7 +573,7 @@ func testExternalCheckpointHappyPath(t *testing.T, s balance.Store) { }) } -func assertBalance(t *testing.T, s balance.Store, tokenAccount string, quarks, usdCostBasis int64, isOpen bool) { +func assertBalance(t *testing.T, s balance.Store, tokenAccount string, quarks uint64, usdCostBasis int64, isOpen bool) { record, err := s.Get(context.Background(), tokenAccount) require.NoError(t, err) assert.EqualValues(t, quarks, record.Quarks, "quarks") @@ -739,7 +589,6 @@ func assertEquivalentRecords(t *testing.T, obj1, obj2 *balance.Record) { assert.Equal(t, obj1.IsLocked, obj2.IsLocked) assert.Equal(t, obj1.UsdCostBasis, obj2.UsdCostBasis) assert.Equal(t, obj1.IsOpen, obj2.IsOpen) - assert.Equal(t, obj1.IsBackfilled, obj2.IsBackfilled) } func assertEquivalentExternalCheckpoingRecords(t *testing.T, obj1, obj2 *balance.ExternalCheckpointRecord) { diff --git a/ocp/data/internal.go b/ocp/data/internal.go index af4a9e0..64bc59e 100644 --- a/ocp/data/internal.go +++ b/ocp/data/internal.go @@ -126,11 +126,10 @@ type DatabaseData interface { GetBalanceBatch(ctx context.Context, tokenAccounts ...string) (map[string]*balance.Record, error) GetAllBalancesByOwner(ctx context.Context, owner string) ([]*balance.Record, error) GetAllBalancesByOwnerAndMint(ctx context.Context, owner, mint string) ([]*balance.Record, error) - GetAllLockedBalancesByMint(ctx context.Context, mint string, minQuarks int64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*balance.Record, error) - CountLockedBalancesByMint(ctx context.Context, mint string, minQuarks int64) (uint64, error) + GetAllLockedBalancesByMint(ctx context.Context, mint string, minQuarks uint64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*balance.Record, error) + CountLockedBalancesByMint(ctx context.Context, mint string, minQuarks uint64) (uint64, error) ApplyBalanceDeltas(ctx context.Context, deltas ...*balance.Delta) error MarkBalanceAsUnlocked(ctx context.Context, tokenAccount string) error - BackfillBalance(ctx context.Context, tokenAccount string, fn balance.BackfillFunc) error SaveExternalBalanceCheckpoint(ctx context.Context, record *balance.ExternalCheckpointRecord) error GetExternalBalanceCheckpoint(ctx context.Context, account string) (*balance.ExternalCheckpointRecord, error) @@ -481,21 +480,18 @@ func (dp *DatabaseProvider) GetAllBalancesByOwner(ctx context.Context, owner str func (dp *DatabaseProvider) GetAllBalancesByOwnerAndMint(ctx context.Context, owner, mint string) ([]*balance.Record, error) { return dp.balance.GetAllByOwnerAndMint(ctx, owner, mint) } -func (dp *DatabaseProvider) GetAllLockedBalancesByMint(ctx context.Context, mint string, minQuarks int64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*balance.Record, error) { +func (dp *DatabaseProvider) GetAllLockedBalancesByMint(ctx context.Context, mint string, minQuarks uint64, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*balance.Record, error) { return dp.balance.GetAllLockedByMint(ctx, mint, minQuarks, cursor, limit, direction) } func (dp *DatabaseProvider) MarkBalanceAsUnlocked(ctx context.Context, tokenAccount string) error { return dp.balance.MarkAsUnlocked(ctx, tokenAccount) } -func (dp *DatabaseProvider) CountLockedBalancesByMint(ctx context.Context, mint string, minQuarks int64) (uint64, error) { +func (dp *DatabaseProvider) CountLockedBalancesByMint(ctx context.Context, mint string, minQuarks uint64) (uint64, error) { return dp.balance.CountLockedByMint(ctx, mint, minQuarks) } func (dp *DatabaseProvider) ApplyBalanceDeltas(ctx context.Context, deltas ...*balance.Delta) error { return dp.balance.ApplyDeltas(ctx, deltas...) } -func (dp *DatabaseProvider) BackfillBalance(ctx context.Context, tokenAccount string, fn balance.BackfillFunc) error { - return dp.balance.Backfill(ctx, tokenAccount, fn) -} func (dp *DatabaseProvider) SaveExternalBalanceCheckpoint(ctx context.Context, record *balance.ExternalCheckpointRecord) error { return dp.balance.SaveExternalCheckpoint(ctx, record) } diff --git a/ocp/rpc/account/server.go b/ocp/rpc/account/server.go index b5ed848..e1b5922 100644 --- a/ocp/rpc/account/server.go +++ b/ocp/rpc/account/server.go @@ -315,21 +315,29 @@ func (s *server) GetTokenAccountInfos(ctx context.Context, req *accountpb.GetTok func (s *server) fetchBalances(ctx context.Context, allAccountRecords []*common.AccountRecords) (map[string]*balanceMetadata, error) { balanceMetadataByTokenAccount := make(map[string]*balanceMetadata) - var mangedByCodeRecords []*common.AccountRecords + // Every Timelock account starts out without a balance, since the caching + // strategy isn't possible for the ones that have left the L2 system. The + // ledger omits those, so they keep this value. + var tokenAccounts []*common.Account for _, accountRecords := range allAccountRecords { - if accountRecords.IsManagedByCode(ctx) { - mangedByCodeRecords = append(mangedByCodeRecords, accountRecords) - } else { - // Don't calculate a balance for now, since the caching strategy - // is not possible. - balanceMetadataByTokenAccount[accountRecords.General.TokenAccount] = &balanceMetadata{ - quarks: 0, - usdCostBasis: 0, - source: accountpb.TokenAccountInfo_BALANCE_SOURCE_UNKNOWN, - } + if !accountRecords.IsTimelock() { + continue + } + + balanceMetadataByTokenAccount[accountRecords.General.TokenAccount] = &balanceMetadata{ + quarks: 0, + usdCostBasis: 0, + source: accountpb.TokenAccountInfo_BALANCE_SOURCE_UNKNOWN, } + + tokenAccount, err := common.NewAccountFromPublicKeyString(accountRecords.General.TokenAccount) + if err != nil { + return nil, err + } + tokenAccounts = append(tokenAccounts, tokenAccount) } - balancesByTokenAccount, err := balance.BatchCalculateWithUsdCostBasisFromCache(ctx, s.data, mangedByCodeRecords...) + + balancesByTokenAccount, err := balance.BatchCalculateFromCache(ctx, s.data, tokenAccounts...) if err != nil { return nil, err } diff --git a/ocp/rpc/account/server_test.go b/ocp/rpc/account/server_test.go index 98eba93..e2a504c 100644 --- a/ocp/rpc/account/server_test.go +++ b/ocp/rpc/account/server_test.go @@ -3,8 +3,6 @@ package account import ( "context" "crypto/ed25519" - "fmt" - "math/rand" "testing" "time" @@ -19,20 +17,20 @@ import ( commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" "github.com/code-payments/ocp-server/currency" + balance_util "github.com/code-payments/ocp-server/ocp/balance" "github.com/code-payments/ocp-server/ocp/common" currency_util "github.com/code-payments/ocp-server/ocp/currency" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/account" "github.com/code-payments/ocp-server/ocp/data/action" + "github.com/code-payments/ocp-server/ocp/data/balance" exchange_memory "github.com/code-payments/ocp-server/ocp/data/currency/exchange/memory" "github.com/code-payments/ocp-server/ocp/data/currency/holder" holder_memory "github.com/code-payments/ocp-server/ocp/data/currency/holder/memory" "github.com/code-payments/ocp-server/ocp/data/currency/reserve" reserve_memory "github.com/code-payments/ocp-server/ocp/data/currency/reserve/memory" - "github.com/code-payments/ocp-server/ocp/data/deposit" "github.com/code-payments/ocp-server/ocp/data/intent" "github.com/code-payments/ocp-server/ocp/data/timelock" - "github.com/code-payments/ocp-server/ocp/data/transaction" "github.com/code-payments/ocp-server/pointer" "github.com/code-payments/ocp-server/solana/currencycreator" timelock_token_v1 "github.com/code-payments/ocp-server/solana/timelock/v1" @@ -519,9 +517,7 @@ func TestGetTokenAccountInfos_RemoteSendGiftCard_HappyPath(t *testing.T) { require.NoError(t, env.data.PutAllActions(env.ctx, claimActionRecord)) } - accountRecords.Timelock.VaultState = tc.timelockState - accountRecords.Timelock.Block += 1 - require.NoError(t, env.data.SaveTimelock(env.ctx, accountRecords.Timelock)) + setTimelockState(t, env, accountRecords, tc.timelockState) for _, requestingOwnerAccount := range []*common.Account{ nil, @@ -631,10 +627,9 @@ func TestGetTokenAccountInfos_BlockchainState(t *testing.T) { } accountRecords := getDefaultTestAccountRecords(t, ownerAccount, ownerAccount, coreVmConfig, 0, commonpb.AccountType_PRIMARY) - accountRecords.Timelock.VaultState = tc.timelockState - accountRecords.Timelock.Block += 1 require.NoError(t, env.data.CreateAccountInfo(env.ctx, accountRecords.General)) - require.NoError(t, env.data.SaveTimelock(env.ctx, accountRecords.Timelock)) + require.NoError(t, balance_util.CreateRecordInTx(env.ctx, env.data, accountRecords.General)) + setTimelockState(t, env, accountRecords, tc.timelockState) resp, err := env.client.GetTokenAccountInfos(env.ctx, req) require.NoError(t, err) @@ -702,7 +697,11 @@ func TestGetTokenAccountInfos_ManagementState(t *testing.T) { accountRecords.Timelock.VaultState = tc.timelockState accountRecords.Timelock.Block = tc.block require.NoError(t, env.data.CreateAccountInfo(env.ctx, accountRecords.General)) + require.NoError(t, balance_util.CreateRecordInTx(env.ctx, env.data, accountRecords.General)) require.NoError(t, env.data.SaveTimelock(env.ctx, accountRecords.Timelock)) + if !accountRecords.Timelock.IsLocked() { + require.NoError(t, env.data.MarkBalanceAsUnlocked(env.ctx, accountRecords.General.TokenAccount)) + } resp, err := env.client.GetTokenAccountInfos(env.ctx, req) require.NoError(t, err) @@ -804,6 +803,7 @@ func setupAccountRecords(t *testing.T, env testEnv, ownerAccount, authorityAccou accountRecords := getDefaultTestAccountRecords(t, ownerAccount, authorityAccount, vmConfig, index, accountType) require.NoError(t, env.data.CreateAccountInfo(env.ctx, accountRecords.General)) + require.NoError(t, balance_util.CreateRecordInTx(env.ctx, env.data, accountRecords.General)) if accountRecords.IsTimelock() { accountRecords.Timelock.VaultState = timelock_token_v1.StateLocked @@ -847,14 +847,22 @@ func getDefaultTestAccountRecords(t *testing.T, ownerAccount, authorityAccount * } } -func setupCachedBalance(t *testing.T, env testEnv, accountRecords *common.AccountRecords, balance uint64) { - depositRecord := &deposit.Record{ - Signature: fmt.Sprintf("txn%d", rand.Uint64()), - Destination: accountRecords.General.TokenAccount, - Amount: balance, +// setTimelockState transitions a vault the way the geyser worker does, moving +// the ledger record's lock state with it so the two can't disagree. +func setTimelockState(t *testing.T, env testEnv, accountRecords *common.AccountRecords, state timelock_token_v1.TimelockState) { + accountRecords.Timelock.VaultState = state + accountRecords.Timelock.Block += 1 + require.NoError(t, env.data.SaveTimelock(env.ctx, accountRecords.Timelock)) - ConfirmationState: transaction.ConfirmationFinalized, - Slot: 12345, + if !accountRecords.Timelock.IsLocked() { + require.NoError(t, env.data.MarkBalanceAsUnlocked(env.ctx, accountRecords.General.TokenAccount)) } - require.NoError(t, env.data.SaveExternalDeposit(env.ctx, depositRecord)) +} + +func setupCachedBalance(t *testing.T, env testEnv, accountRecords *common.AccountRecords, quarks uint64) { + require.NoError(t, balance_util.ApplyDeltasInTx(env.ctx, env.data, &balance.Delta{ + TokenAccount: accountRecords.General.TokenAccount, + Kind: balance.DeltaCredit, + Quarks: quarks, + })) } diff --git a/ocp/rpc/balance/server.go b/ocp/rpc/balance/server.go index d83413c..b383252 100644 --- a/ocp/rpc/balance/server.go +++ b/ocp/rpc/balance/server.go @@ -74,40 +74,17 @@ func (s *server) GetBalance(ctx context.Context, req *balancepb.GetBalanceReques } func (s *server) calculateCoreMintValue(ctx context.Context, owner *common.Account) (uint64, error) { - recordsByMintAndType, err := common.GetLatestCodeTimelockAccountRecordsForOwner(ctx, s.data, owner) - if err != nil { - return 0, err - } - - // Accounts that have left the L2 system don't have a cached balance that can - // be trusted, so they're excluded from the calculation. - mintByTokenAccount := make(map[string]string) - var managedByCodeRecords []*common.AccountRecords - for mint, recordsByType := range recordsByMintAndType { - for _, recordsList := range recordsByType { - for _, records := range recordsList { - if !records.IsManagedByCode(ctx) { - continue - } - - mintByTokenAccount[records.General.TokenAccount] = mint - managedByCodeRecords = append(managedByCodeRecords, records) - } - } - } - - if len(managedByCodeRecords) == 0 { - return 0, nil - } - - balanceByTokenAccount, err := balance.BatchCalculateFromCacheWithAccountRecords(ctx, s.data, managedByCodeRecords...) + // The ledger holds a record for every account Code manages for the owner, + // and each carries the mint it holds. Accounts that have left the L2 system + // don't have a cached balance that can be trusted, so it omits them. + balanceByTokenAccount, err := balance.BatchCalculateFromCacheByOwner(ctx, s.data, owner) if err != nil { return 0, err } quarksByMint := make(map[string]uint64) - for tokenAccount, quarks := range balanceByTokenAccount { - quarksByMint[mintByTokenAccount[tokenAccount]] += quarks + for _, cached := range balanceByTokenAccount { + quarksByMint[cached.MintAccount] += cached.Quarks } var coreMintValue uint64 diff --git a/ocp/rpc/balance/server_test.go b/ocp/rpc/balance/server_test.go index 27d90eb..26abe2a 100644 --- a/ocp/rpc/balance/server_test.go +++ b/ocp/rpc/balance/server_test.go @@ -2,8 +2,6 @@ package balance import ( "context" - "fmt" - "math/rand" "testing" "time" @@ -15,17 +13,17 @@ import ( balancepb "github.com/code-payments/ocp-protobuf-api/generated/go/balance/v1" commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" + balance_util "github.com/code-payments/ocp-server/ocp/balance" "github.com/code-payments/ocp-server/ocp/common" currency_util "github.com/code-payments/ocp-server/ocp/currency" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/account" + "github.com/code-payments/ocp-server/ocp/data/balance" exchange_memory "github.com/code-payments/ocp-server/ocp/data/currency/exchange/memory" "github.com/code-payments/ocp-server/ocp/data/currency/holder" holder_memory "github.com/code-payments/ocp-server/ocp/data/currency/holder/memory" "github.com/code-payments/ocp-server/ocp/data/currency/reserve" reserve_memory "github.com/code-payments/ocp-server/ocp/data/currency/reserve/memory" - "github.com/code-payments/ocp-server/ocp/data/deposit" - "github.com/code-payments/ocp-server/ocp/data/transaction" "github.com/code-payments/ocp-server/solana/currencycreator" timelock_token_v1 "github.com/code-payments/ocp-server/solana/timelock/v1" "github.com/code-payments/ocp-server/testutil" @@ -133,9 +131,12 @@ func TestGetBalance_UnmanagedAccountsExcluded(t *testing.T) { // The pool account has left the L2 system, so there isn't a cached balance that // can be trusted for it. + // The geyser worker moves both records in the same transaction, so the + // ledger record's lock state can't disagree with the timelock record's primaryCoreMintAccountRecords.Timelock.VaultState = timelock_token_v1.StateUnlocked primaryCoreMintAccountRecords.Timelock.Block += 1 require.NoError(t, env.data.SaveTimelock(env.ctx, primaryCoreMintAccountRecords.Timelock)) + require.NoError(t, env.data.MarkBalanceAsUnlocked(env.ctx, primaryCoreMintAccountRecords.General.TokenAccount)) resp, err := env.client.GetBalance(env.ctx, &balancepb.GetBalanceRequest{ Owner: ownerAccount.ToProto(), @@ -197,6 +198,7 @@ func setupAccountRecords(t *testing.T, env testEnv, ownerAccount, authorityAccou require.NoError(t, env.data.CreateAccountInfo(env.ctx, accountInfoRecord)) require.NoError(t, env.data.SaveTimelock(env.ctx, timelockRecord)) + require.NoError(t, balance_util.CreateRecordInTx(env.ctx, env.data, accountInfoRecord)) return &common.AccountRecords{ General: accountInfoRecord, @@ -204,14 +206,10 @@ func setupAccountRecords(t *testing.T, env testEnv, ownerAccount, authorityAccou } } -func setupCachedBalance(t *testing.T, env testEnv, accountRecords *common.AccountRecords, balance uint64) { - depositRecord := &deposit.Record{ - Signature: fmt.Sprintf("txn%d", rand.Uint64()), - Destination: accountRecords.General.TokenAccount, - Amount: balance, - - ConfirmationState: transaction.ConfirmationFinalized, - Slot: 12345, - } - require.NoError(t, env.data.SaveExternalDeposit(env.ctx, depositRecord)) +func setupCachedBalance(t *testing.T, env testEnv, accountRecords *common.AccountRecords, quarks uint64) { + require.NoError(t, balance_util.ApplyDeltasInTx(env.ctx, env.data, &balance.Delta{ + TokenAccount: accountRecords.General.TokenAccount, + Kind: balance.DeltaCredit, + Quarks: quarks, + })) } diff --git a/ocp/rpc/transaction/intent.go b/ocp/rpc/transaction/intent.go index b2d1e2f..e6f6668 100644 --- a/ocp/rpc/transaction/intent.go +++ b/ocp/rpc/transaction/intent.go @@ -720,17 +720,15 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm // Reflect the intent in the balance ledger. Applied last, so the // ledger row locks are held for as little of the transaction as // possible. - if balance.LedgerWritesEnabled(ctx) { - balanceDeltas, err := balance.DeltasForSubmittedIntent(intentRecord, actionRecords, s.conf.createOnSendWithdrawalFeeQuarks.Get(ctx)) - if err != nil { - log.With(zap.Error(err)).Warn("failure building balance deltas") - return err - } - err = balance.ApplyDeltasInTx(ctx, s.data, balanceDeltas...) - if err != nil { - log.With(zap.Error(err)).Warn("failure applying balance deltas") - return err - } + balanceDeltas, err := balance.DeltasForSubmittedIntent(intentRecord, actionRecords, s.conf.createOnSendWithdrawalFeeQuarks.Get(ctx)) + if err != nil { + log.With(zap.Error(err)).Warn("failure building balance deltas") + return err + } + err = balance.ApplyDeltasInTx(ctx, s.data, balanceDeltas...) + if err != nil { + log.With(zap.Error(err)).Warn("failure applying balance deltas") + return err } // Schedule app-defined tasks atomically with the intent, so their diff --git a/ocp/rpc/transaction/local_simulation.go b/ocp/rpc/transaction/local_simulation.go index 4e47635..b1c5fb1 100644 --- a/ocp/rpc/transaction/local_simulation.go +++ b/ocp/rpc/transaction/local_simulation.go @@ -340,14 +340,18 @@ func LocalSimulation(ctx context.Context, data ocp_data.Provider, actions []*tra tokenAccountsToFetchBalance = append(tokenAccountsToFetchBalance, sim.TokenAccount) } } - prefetchedBalances := make(map[string]uint64) + prefetchedBalances := make(map[string]*balance.Balance) if len(tokenAccountsToFetchBalance) > 0 { - prefetchedBalances, err = balance.BatchCalculateFromCacheWithTokenAccounts(ctx, data, tokenAccountsToFetchBalance...) - if err == balance.ErrNotManagedByCode { - return nil, ErrSourceNotManagedByCode - } else if err != nil { + prefetchedBalances, err = balance.BatchCalculateFromCache(ctx, data, tokenAccountsToFetchBalance...) + if err != nil { return nil, err } + + // The ledger omits accounts it doesn't manage, and every account we + // simulate against must be managed + if len(prefetchedBalances) != len(tokenAccountsToFetchBalance) { + return nil, ErrSourceNotManagedByCode + } } // Do more complex simulation validation on each involved account using all combined actions @@ -356,16 +360,16 @@ func LocalSimulation(ctx context.Context, data ocp_data.Provider, actions []*tra continue } - var ok bool - var balance uint64 + var quarks uint64 if sim.RequiresBalanceFetch() { - balance, ok = prefetchedBalances[sim.TokenAccount.PublicKey().ToBase58()] + cached, ok := prefetchedBalances[sim.TokenAccount.PublicKey().ToBase58()] if !ok { return nil, errors.New("prefetched balance is unavailable") } + quarks = cached.Quarks } - err := sim.EnforceBalances(ctx, data, balance) + err := sim.EnforceBalances(ctx, data, quarks) if err != nil { return nil, err } diff --git a/ocp/worker/account/gift_card.go b/ocp/worker/account/gift_card.go index 031c15b..7c32442 100644 --- a/ocp/worker/account/gift_card.go +++ b/ocp/worker/account/gift_card.go @@ -233,15 +233,13 @@ func InitiateProcessToAutoReturnGiftCard(ctx context.Context, data ocp_data.Prov return err } - if balance.LedgerWritesEnabled(ctx) { - balanceDeltas, err := balance.DeltasForGiftCardAutoReturn(autoReturnIntent, autoReturnAction) - if err != nil { - return err - } - err = balance.ApplyDeltasInTx(ctx, data, balanceDeltas...) - if err != nil { - return err - } + balanceDeltas, err := balance.DeltasForGiftCardAutoReturn(autoReturnIntent, autoReturnAction) + if err != nil { + return err + } + err = balance.ApplyDeltasInTx(ctx, data, balanceDeltas...) + if err != nil { + return err } // This will trigger the fulfillment worker to poll for the fulfillment. This diff --git a/ocp/worker/account/testutil.go b/ocp/worker/account/testutil.go index 18827dc..e9031ae 100644 --- a/ocp/worker/account/testutil.go +++ b/ocp/worker/account/testutil.go @@ -13,10 +13,12 @@ import ( commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" currency_lib "github.com/code-payments/ocp-server/currency" + "github.com/code-payments/ocp-server/ocp/balance" "github.com/code-payments/ocp-server/ocp/common" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/account" "github.com/code-payments/ocp-server/ocp/data/action" + balance_data "github.com/code-payments/ocp-server/ocp/data/balance" "github.com/code-payments/ocp-server/ocp/data/fulfillment" "github.com/code-payments/ocp-server/ocp/data/intent" "github.com/code-payments/ocp-server/ocp/data/timelock" @@ -106,6 +108,15 @@ func (e *testEnv) generateRandomGiftCard(t *testing.T, creationTs time.Time) *te } require.NoError(t, e.data.SaveIntent(e.ctx, intentRecord)) + // The issuance funded the gift card, which the ledger recorded + require.NoError(t, balance.CreateRecordInTx(e.ctx, e.data, accountInfoRecord)) + require.NoError(t, balance.ApplyDeltasInTx(e.ctx, e.data, &balance_data.Delta{ + TokenAccount: accountInfoRecord.TokenAccount, + Kind: balance_data.DeltaCredit, + Quarks: intentRecord.SendPublicPaymentMetadata.Quantity, + UsdCostBasis: balance_data.UsdCostBasisFromFloat(intentRecord.SendPublicPaymentMetadata.UsdMarketValue), + })) + autoReturnActionRecord := &action.Record{ Intent: intentRecord.IntentId, IntentType: intentRecord.IntentType, diff --git a/ocp/worker/currency/holder/runtime.go b/ocp/worker/currency/holder/runtime.go index 72d78e4..c9e3f66 100644 --- a/ocp/worker/currency/holder/runtime.go +++ b/ocp/worker/currency/holder/runtime.go @@ -4,16 +4,11 @@ import ( "context" "time" - "github.com/pkg/errors" "go.uber.org/zap" - commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" - "github.com/code-payments/ocp-server/metrics" - "github.com/code-payments/ocp-server/ocp/balance" "github.com/code-payments/ocp-server/ocp/common" ocp_data "github.com/code-payments/ocp-server/ocp/data" - "github.com/code-payments/ocp-server/ocp/data/account" "github.com/code-payments/ocp-server/ocp/data/currency" currency_holder "github.com/code-payments/ocp-server/ocp/data/currency/holder" "github.com/code-payments/ocp-server/ocp/data/currency/reserve" @@ -115,64 +110,5 @@ func (p *holderRuntime) countHoldersForMint(ctx context.Context, mint string, cu return 0, nil } - if balance.LedgerReadsEnabled(ctx) { - return p.data.CountLockedBalancesByMint(ctx, mint, int64(minHoldings)) - } - - accountRecords, err := p.data.GetAccountInfosByMintAndType(ctx, mint, commonpb.AccountType_PRIMARY) - if err == account.ErrAccountInfoNotFound { - return 0, nil - } else if err != nil { - return 0, errors.Wrap(err, "error getting primary accounts") - } - - if len(accountRecords) == 0 { - return 0, nil - } - - vaultAddresses := make([]string, len(accountRecords)) - for i, record := range accountRecords { - vaultAddresses[i] = record.TokenAccount - } - - timelockRecordsByVault, err := p.data.GetTimelockByVaultBatch(ctx, vaultAddresses...) - if err != nil { - return 0, errors.Wrap(err, "error getting timelock records") - } - - tokenAccounts := make([]*common.Account, 0) - for _, record := range accountRecords { - timelockRecord, ok := timelockRecordsByVault[record.TokenAccount] - if !ok { - return 0, errors.Errorf("timelock record unexpectedly missing for vault %s", record.TokenAccount) - } - - if !timelockRecord.IsLocked() { - continue - } - - tokenAccount, err := common.NewAccountFromPublicKeyString(record.TokenAccount) - if err != nil { - return 0, errors.Wrap(err, "invalid token account public key") - } - tokenAccounts = append(tokenAccounts, tokenAccount) - } - - if len(tokenAccounts) == 0 { - return 0, nil - } - - balances, err := balance.BatchCalculateFromCacheWithTokenAccounts(ctx, p.data, tokenAccounts...) - if err != nil { - return 0, errors.Wrap(err, "error calculating balances batch") - } - - var count uint64 - for _, bal := range balances { - if bal >= minHoldings { - count++ - } - } - - return count, nil + return p.data.CountLockedBalancesByMint(ctx, mint, minHoldings) } diff --git a/ocp/worker/geyser/external_deposit.go b/ocp/worker/geyser/external_deposit.go index 9e2356d..cc4d553 100644 --- a/ocp/worker/geyser/external_deposit.go +++ b/ocp/worker/geyser/external_deposit.go @@ -406,15 +406,13 @@ func processPotentialExternalDepositIntoVm(ctx context.Context, data ocp_data.Pr return errors.Wrap(err, "error saving external deposit record") } - if balance_util.LedgerWritesEnabled(ctx) { - balanceDeltas, err := balance_util.DeltasForExternalDeposit(intentRecord) - if err != nil { - return errors.Wrap(err, "error building balance deltas") - } - err = balance_util.ApplyDeltasInTx(ctx, data, balanceDeltas...) - if err != nil { - return errors.Wrap(err, "error applying balance deltas") - } + balanceDeltas, err := balance_util.DeltasForExternalDeposit(intentRecord) + if err != nil { + return errors.Wrap(err, "error building balance deltas") + } + err = balance_util.ApplyDeltasInTx(ctx, data, balanceDeltas...) + if err != nil { + return errors.Wrap(err, "error applying balance deltas") } return nil diff --git a/ocp/worker/swap/util.go b/ocp/worker/swap/util.go index 85e764a..e154c5f 100644 --- a/ocp/worker/swap/util.go +++ b/ocp/worker/swap/util.go @@ -465,15 +465,13 @@ func (p *runtime) markSwapCancelled(ctx context.Context, swapRecord *swap.Record return err } - if balance.LedgerWritesEnabled(ctx) { - balanceDeltas, err := balance.DeltasForExternalDeposit(refundIntentRecord) - if err != nil { - return err - } - err = balance.ApplyDeltasInTx(ctx, p.data, balanceDeltas...) - if err != nil { - return err - } + balanceDeltas, err := balance.DeltasForExternalDeposit(refundIntentRecord) + if err != nil { + return err + } + err = balance.ApplyDeltasInTx(ctx, p.data, balanceDeltas...) + if err != nil { + return err } // The swap was funded and entered transaction history, so its @@ -854,28 +852,26 @@ func (p *runtime) maybeUpdateBalancesForFinalizedReserveSwap(ctx context.Context } } - if balance.LedgerWritesEnabled(ctx) { - balanceDeltas, err := balance.DeltasForExternalDeposit(intentRecord) + balanceDeltas, err := balance.DeltasForExternalDeposit(intentRecord) + if err != nil { + return err + } + + if reconciledFundingIntentRecord != nil { + fundingActionRecords, err := p.data.GetAllActionsByIntent(ctx, reconciledFundingIntentRecord.IntentId) if err != nil { return err } - - if reconciledFundingIntentRecord != nil { - fundingActionRecords, err := p.data.GetAllActionsByIntent(ctx, reconciledFundingIntentRecord.IntentId) - if err != nil { - return err - } - reconciliationDeltas, err := balance.DeltasForSwapSellReconciliation(previousFundingIntentRecord, reconciledFundingIntentRecord, fundingActionRecords) - if err != nil { - return err - } - balanceDeltas = append(balanceDeltas, reconciliationDeltas...) - } - - err = balance.ApplyDeltasInTx(ctx, p.data, balanceDeltas...) + reconciliationDeltas, err := balance.DeltasForSwapSellReconciliation(previousFundingIntentRecord, reconciledFundingIntentRecord, fundingActionRecords) if err != nil { return err } + balanceDeltas = append(balanceDeltas, reconciliationDeltas...) + } + + err = balance.ApplyDeltasInTx(ctx, p.data, balanceDeltas...) + if err != nil { + return err } return nil