diff --git a/go.mod b/go.mod index 4c8b07e0..b9bc76d7 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( filippo.io/edwards25519 v1.1.0 github.com/aws/aws-sdk-go-v2 v0.17.0 github.com/bits-and-blooms/bloom/v3 v3.1.0 - github.com/code-payments/code-protobuf-api v1.19.1-0.20250610140050-4cadbcc86f16 + github.com/code-payments/code-protobuf-api v1.19.1-0.20250618155621-c66659ab4ff5 github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v1.2.1 diff --git a/go.sum b/go.sum index 9f8ddbb8..e51b9d05 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/code-payments/code-protobuf-api v1.19.1-0.20250610140050-4cadbcc86f16 h1:drAMKRdbyObW8E4H6xc1pKIDxoFYgpaTdMlEnIKBIJ0= -github.com/code-payments/code-protobuf-api v1.19.1-0.20250610140050-4cadbcc86f16/go.mod h1:ee6TzKbgMS42ZJgaFEMG3c4R3dGOiffHSu6MrY7WQvs= +github.com/code-payments/code-protobuf-api v1.19.1-0.20250618155621-c66659ab4ff5 h1:fsZBRUCPGSZQngq/1rsJcEwhiYrZokr0wketRRTgGmI= +github.com/code-payments/code-protobuf-api v1.19.1-0.20250618155621-c66659ab4ff5/go.mod h1:ee6TzKbgMS42ZJgaFEMG3c4R3dGOiffHSu6MrY7WQvs= github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba h1:Bkp+gmeb6Y2PWXfkSCTMBGWkb2P1BujRDSjWeI+0j5I= github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba/go.mod h1:jSiifpiBpyBQ8q2R0MGEbkSgWC6sbdRTyDBntmW+j1E= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw= diff --git a/pkg/code/aml/guard.go b/pkg/code/aml/guard.go index b7eca4b4..21f390f4 100644 --- a/pkg/code/aml/guard.go +++ b/pkg/code/aml/guard.go @@ -63,6 +63,9 @@ func (g *Guard) AllowMoneyMovement(ctx context.Context, intentRecord *intent.Rec case intent.ReceivePaymentsPublicly: // Public receives are always allowed return true, nil + case intent.PublicDistribution: + // Public distributions are always allowed + return true, nil default: err := errors.New("intent record must be a send or receive payment") tracer.OnError(err) diff --git a/pkg/code/antispam/guard.go b/pkg/code/antispam/guard.go index ee35504a..cc1637f5 100644 --- a/pkg/code/antispam/guard.go +++ b/pkg/code/antispam/guard.go @@ -3,6 +3,8 @@ package antispam import ( "context" + transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2" + "github.com/code-payments/code-server/pkg/code/common" "github.com/code-payments/code-server/pkg/metrics" ) @@ -15,11 +17,11 @@ func NewGuard(integration Integration) *Guard { return &Guard{integration: integration} } -func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account) (bool, error) { +func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, accountSet transactionpb.OpenAccountsMetadata_AccountSet) (bool, error) { tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowOpenAccounts") defer tracer.End() - allow, reason, err := g.integration.AllowOpenAccounts(ctx, owner) + allow, reason, err := g.integration.AllowOpenAccounts(ctx, owner, accountSet) if err != nil { return false, err } @@ -70,3 +72,17 @@ func (g *Guard) AllowReceivePayments(ctx context.Context, owner *common.Account, } return allow, nil } + +func (g *Guard) AllowDistribution(ctx context.Context, owner *common.Account, isPublic bool) (bool, error) { + tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowDistribution") + defer tracer.End() + + allow, reason, err := g.integration.AllowDistribution(ctx, owner, isPublic) + if err != nil { + return false, err + } + if !allow { + recordDenialEvent(ctx, actionDistribution, reason) + } + return allow, nil +} diff --git a/pkg/code/antispam/integration.go b/pkg/code/antispam/integration.go index 8c800c11..d0aa317e 100644 --- a/pkg/code/antispam/integration.go +++ b/pkg/code/antispam/integration.go @@ -3,19 +3,23 @@ package antispam import ( "context" + transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2" + "github.com/code-payments/code-server/pkg/code/common" ) // Integration is an antispam guard integration that apps can implement to check // whether operations of interest are allowed to be performed. type Integration interface { - AllowOpenAccounts(ctx context.Context, owner *common.Account) (bool, string, error) + AllowOpenAccounts(ctx context.Context, owner *common.Account, accountSet transactionpb.OpenAccountsMetadata_AccountSet) (bool, string, error) AllowWelcomeBonus(ctx context.Context, owner *common.Account) (bool, string, error) AllowSendPayment(ctx context.Context, owner, destination *common.Account, isPublic bool) (bool, string, error) AllowReceivePayments(ctx context.Context, owner *common.Account, isPublic bool) (bool, string, error) + + AllowDistribution(ctx context.Context, owner *common.Account, isPublic bool) (bool, string, error) } type allowEverythingIntegration struct { @@ -26,7 +30,7 @@ func NewAllowEverything() Integration { return &allowEverythingIntegration{} } -func (i *allowEverythingIntegration) AllowOpenAccounts(ctx context.Context, owner *common.Account) (bool, string, error) { +func (i *allowEverythingIntegration) AllowOpenAccounts(ctx context.Context, owner *common.Account, accountSet transactionpb.OpenAccountsMetadata_AccountSet) (bool, string, error) { return true, "", nil } @@ -41,3 +45,7 @@ func (i *allowEverythingIntegration) AllowSendPayment(ctx context.Context, owner func (i *allowEverythingIntegration) AllowReceivePayments(ctx context.Context, owner *common.Account, isPublic bool) (bool, string, error) { return true, "", nil } + +func (i *allowEverythingIntegration) AllowDistribution(ctx context.Context, owner *common.Account, isPublic bool) (bool, string, error) { + return true, "", nil +} diff --git a/pkg/code/antispam/metrics.go b/pkg/code/antispam/metrics.go index 16b9cb41..4e2efb65 100644 --- a/pkg/code/antispam/metrics.go +++ b/pkg/code/antispam/metrics.go @@ -14,6 +14,7 @@ const ( actionOpenAccounts = "OpenAccounts" actionSendPayment = "SendPayment" actionReceivePayments = "ReceivePayments" + actionDistribution = "Distribution" actionWelcomeBonus = "WelcomeBonus" ) diff --git a/pkg/code/async/account/gift_card.go b/pkg/code/async/account/gift_card.go index 11a4c5f6..61451794 100644 --- a/pkg/code/async/account/gift_card.go +++ b/pkg/code/async/account/gift_card.go @@ -211,7 +211,7 @@ func InitiateProcessToAutoReturnGiftCard(ctx context.Context, data code_data.Pro return err } - return balanceLock.OnCommit(ctx, data) + return balanceLock.OnNewBalanceVersion(ctx, data) }) } diff --git a/pkg/code/async/sequencer/intent_handler.go b/pkg/code/async/sequencer/intent_handler.go index 29addbf5..33c320cc 100644 --- a/pkg/code/async/sequencer/intent_handler.go +++ b/pkg/code/async/sequencer/intent_handler.go @@ -125,6 +125,37 @@ func (h *ReceivePaymentsPubliclyIntentHandler) OnActionUpdated(ctx context.Conte return nil } +type PublicDistributionIntentHandler struct { + data code_data.Provider +} + +func NewPublicDistributionIntentHandler(data code_data.Provider) IntentHandler { + return &PublicDistributionIntentHandler{ + data: data, + } +} + +func (h *PublicDistributionIntentHandler) OnActionUpdated(ctx context.Context, intentId string) error { + actionRecords, err := h.data.GetAllActionsByIntent(ctx, intentId) + if err != nil { + return err + } + + for _, actionRecord := range actionRecords { + // Intent is failed if at least one transfer or withdraw action fails + if actionRecord.State == action.StateFailed { + return markIntentFailed(ctx, h.data, intentId) + } + + if actionRecord.State != action.StateConfirmed { + return nil + } + } + + // Intent is confirmed when all transfer and withdraw actions are confirmed + return markIntentConfirmed(ctx, h.data, intentId) +} + func validateIntentState(record *intent.Record, states ...intent.State) error { for _, validState := range states { if record.State == validState { @@ -176,5 +207,6 @@ func getIntentHandlers(data code_data.Provider) map[intent.Type]IntentHandler { handlersByType[intent.OpenAccounts] = NewOpenAccountsIntentHandler(data) handlersByType[intent.SendPublicPayment] = NewSendPublicPaymentIntentHandler(data) handlersByType[intent.ReceivePaymentsPublicly] = NewReceivePaymentsPubliclyIntentHandler(data) + handlersByType[intent.PublicDistribution] = NewPublicDistributionIntentHandler(data) return handlersByType } diff --git a/pkg/code/balance/lock.go b/pkg/code/balance/lock.go index 8b47a838..1b2c3ede 100644 --- a/pkg/code/balance/lock.go +++ b/pkg/code/balance/lock.go @@ -2,9 +2,11 @@ package balance import ( "context" + "errors" "github.com/code-payments/code-server/pkg/code/common" code_data "github.com/code-payments/code-server/pkg/code/data" + "github.com/code-payments/code-server/pkg/code/data/balance" ) // OptimisticVersionLock is an optimistic version lock on an account's cached @@ -28,7 +30,46 @@ func GetOptimisticVersionLock(ctx context.Context, data code_data.Provider, vaul }, nil } -// OnCommit is called in the DB transaction updating the account's cached balance -func (l *OptimisticVersionLock) OnCommit(ctx context.Context, data code_data.Provider) error { +// OnNewBalanceVersion is called in the DB transaction updating the account's +// cached balance +func (l *OptimisticVersionLock) OnNewBalanceVersion(ctx context.Context, data code_data.Provider) error { return data.AdvanceCachedBalanceVersion(ctx, l.vault.PublicKey().ToBase58(), l.currentVersion) } + +// RequireSameBalanceVerion is called in the DB transaction requireing the +// account's cached balance not be changed +func (l *OptimisticVersionLock) RequireSameBalanceVerion(ctx context.Context, data code_data.Provider) error { + latestVersion, err := data.GetCachedBalanceVersion(ctx, l.vault.PublicKey().ToBase58()) + if err != nil { + return err + } + if latestVersion < l.currentVersion { + return errors.New("unexpected balance version detected") + } + if l.currentVersion != latestVersion { + return balance.ErrStaleCachedBalanceVersion + } + return nil +} + +// OpenCloseStatusLock is a lock on an account's open/close status +type OpenCloseStatusLock struct { + vault *common.Account +} + +func NewOpenCloseStatusLock(vault *common.Account) *OpenCloseStatusLock { + return &OpenCloseStatusLock{ + vault: vault, + } +} + +// OnPaymentToAccount is called in the DB transaction making a payment to the +// account that may be closed +func (l *OpenCloseStatusLock) OnPaymentToAccount(ctx context.Context, data code_data.Provider) error { + return data.CheckNotClosedForBalanceUpdate(ctx, l.vault.PublicKey().ToBase58()) +} + +// OnClose is called in the DB transaction closing the account +func (l *OpenCloseStatusLock) OnClose(ctx context.Context, data code_data.Provider) error { + return data.MarkAsClosedForBalanceUpdate(ctx, l.vault.PublicKey().ToBase58()) +} diff --git a/pkg/code/common/owner_test.go b/pkg/code/common/owner_test.go index c1c38c18..67e11c18 100644 --- a/pkg/code/common/owner_test.go +++ b/pkg/code/common/owner_test.go @@ -136,6 +136,8 @@ func TestGetLatestTokenAccountRecordsForOwner(t *testing.T) { authority3 := newRandomTestAccount(t) authority4 := newRandomTestAccount(t) authority5 := newRandomTestAccount(t) + authority6 := newRandomTestAccount(t) + authority7 := newRandomTestAccount(t) for _, authorityAndType := range []struct { account *Account @@ -195,9 +197,30 @@ func TestGetLatestTokenAccountRecordsForOwner(t *testing.T) { } require.NoError(t, data.CreateAccountInfo(ctx, swapAccountInfoRecord)) + for i, authority := range []*Account{ + authority6, + authority7, + } { + timelockAccounts, err := authority.GetTimelockAccounts(vmAccount, coreMintAccount) + require.NoError(t, err) + + timelockRecord := timelockAccounts.ToDBRecord() + require.NoError(t, data.SaveTimelock(ctx, timelockRecord)) + + accountInfoRecord := &account.Record{ + OwnerAccount: owner.PublicKey().ToBase58(), + AuthorityAccount: timelockRecord.VaultOwner, + TokenAccount: timelockRecord.VaultAddress, + MintAccount: coreMintAccount.PublicKey().ToBase58(), + AccountType: commonpb.AccountType_POOL, + Index: uint64(i), + } + require.NoError(t, data.CreateAccountInfo(ctx, accountInfoRecord)) + } + actual, err = GetLatestTokenAccountRecordsForOwner(ctx, data, owner) require.NoError(t, err) - require.Len(t, actual, 4) + require.Len(t, actual, 5) records, ok := actual[commonpb.AccountType_BUCKET_1_KIN] require.True(t, ok) @@ -238,4 +261,20 @@ func TestGetLatestTokenAccountRecordsForOwner(t *testing.T) { assert.Equal(t, records[0].General.AuthorityAccount, authority5.PublicKey().ToBase58()) assert.Equal(t, records[0].General.TokenAccount, swapAta.PublicKey().ToBase58()) assert.Equal(t, records[0].General.AccountType, commonpb.AccountType_SWAP) + + records, ok = actual[commonpb.AccountType_POOL] + require.True(t, ok) + require.Len(t, records, 2) + + assert.Equal(t, records[0].General.AuthorityAccount, authority6.PublicKey().ToBase58()) + assert.Equal(t, records[0].General.AccountType, commonpb.AccountType_POOL) + assert.Equal(t, records[0].Timelock.VaultOwner, authority6.PublicKey().ToBase58()) + assert.Equal(t, records[0].General.TokenAccount, records[0].Timelock.VaultAddress) + assert.EqualValues(t, records[0].General.Index, 0) + + assert.Equal(t, records[1].General.AuthorityAccount, authority7.PublicKey().ToBase58()) + assert.Equal(t, records[1].General.AccountType, commonpb.AccountType_POOL) + assert.Equal(t, records[1].Timelock.VaultOwner, authority7.PublicKey().ToBase58()) + assert.Equal(t, records[1].General.TokenAccount, records[1].Timelock.VaultAddress) + assert.EqualValues(t, records[1].General.Index, 1) } diff --git a/pkg/code/data/account/acccount_info.go b/pkg/code/data/account/acccount_info.go index e09db3b7..a8f091a9 100644 --- a/pkg/code/data/account/acccount_info.go +++ b/pkg/code/data/account/acccount_info.go @@ -24,6 +24,7 @@ var AllAccountTypes = []commonpb.AccountType{ commonpb.AccountType_REMOTE_SEND_GIFT_CARD, commonpb.AccountType_RELATIONSHIP, commonpb.AccountType_SWAP, + commonpb.AccountType_POOL, } type Record struct { @@ -180,6 +181,10 @@ func (r *Record) Validate() error { if r.OwnerAccount == r.AuthorityAccount { return errors.New("owner cannot be authority for swap account") } + case commonpb.AccountType_POOL: + if r.OwnerAccount == r.AuthorityAccount { + return errors.New("owner cannot be authority pool account") + } default: return errors.Errorf("unhandled account type: %s", r.AccountType.String()) } diff --git a/pkg/code/data/account/memory/store.go b/pkg/code/data/account/memory/store.go index 1e5ae63e..34ee5228 100644 --- a/pkg/code/data/account/memory/store.go +++ b/pkg/code/data/account/memory/store.go @@ -275,6 +275,10 @@ func (s *store) GetLatestByOwnerAddress(_ context.Context, address string) (map[ items := s.findByOwnerAddress(address) for _, accountType := range account.AllAccountTypes { + if accountType == commonpb.AccountType_POOL { + continue + } + items := s.filterByType(items, accountType) if len(items) == 0 { continue @@ -292,6 +296,11 @@ func (s *store) GetLatestByOwnerAddress(_ context.Context, address string) (map[ } } + items = s.filterByType(items, commonpb.AccountType_POOL) + if len(items) > 0 { + res[commonpb.AccountType_POOL] = cloneRecords(items) + } + if len(res) == 0 { return nil, account.ErrAccountInfoNotFound } diff --git a/pkg/code/data/account/postgres/model.go b/pkg/code/data/account/postgres/model.go index a8a35b55..9d8a3b5d 100644 --- a/pkg/code/data/account/postgres/model.go +++ b/pkg/code/data/account/postgres/model.go @@ -210,27 +210,47 @@ func dbGetByAuthorityAddress(ctx context.Context, db *sqlx.DB, address string) ( } func dbGetLatestByOwnerAddress(ctx context.Context, db *sqlx.DB, address string) ([]*model, error) { - var res []*model + var res1 []*model - query := `SELECT DISTINCT ON (account_type, relationship_to) id, owner_account, authority_account, token_account, mint_account, account_type, index, relationship_to, requires_deposit_sync, deposits_last_synced_at, requires_auto_return_check, requires_swap_retry, last_swap_retry_at, created_at FROM ` + tableName + ` - WHERE owner_account = $1 + query1 := `SELECT DISTINCT ON (account_type, relationship_to) id, owner_account, authority_account, token_account, mint_account, account_type, index, relationship_to, requires_deposit_sync, deposits_last_synced_at, requires_auto_return_check, requires_swap_retry, last_swap_retry_at, created_at FROM ` + tableName + ` + WHERE owner_account = $1 AND account_type NOT IN ($2) ORDER BY account_type, relationship_to, index DESC ` err := db.SelectContext( ctx, - &res, - query, + &res1, + query1, address, + commonpb.AccountType_POOL, ) - if err != nil { - return nil, pgutil.CheckNoRows(err, account.ErrAccountInfoNotFound) + if err != nil && !pgutil.IsNoRows(err) { + return nil, err + } + + var res2 []*model + + query2 := `SELECT id, owner_account, authority_account, token_account, mint_account, account_type, index, relationship_to, requires_deposit_sync, deposits_last_synced_at, requires_auto_return_check, requires_swap_retry, last_swap_retry_at, created_at FROM ` + tableName + ` + WHERE owner_account = $1 AND account_type IN ($2) + ORDER BY index ASC + ` + err = db.SelectContext( + ctx, + &res2, + query2, + address, + commonpb.AccountType_POOL, + ) + if err != nil && !pgutil.IsNoRows(err) { + return nil, err } + var res []*model + res = append(res, res1...) + res = append(res, res2...) if len(res) == 0 { return nil, account.ErrAccountInfoNotFound } - return res, nil } diff --git a/pkg/code/data/account/store.go b/pkg/code/data/account/store.go index 2867a2bb..ea743a7d 100644 --- a/pkg/code/data/account/store.go +++ b/pkg/code/data/account/store.go @@ -29,9 +29,14 @@ type Store interface { GetByAuthorityAddress(ctx context.Context, address string) (*Record, error) // GetLatestByOwnerAddress gets the latest accounts for an owner + // + // For account types where only 1 account can exist, the record with the latest index is returned. + // For account types where more than 1 account can exist, all records are returend. GetLatestByOwnerAddress(ctx context.Context, address string) (map[commonpb.AccountType][]*Record, error) - // GetLatestByOwnerAddressAndType gets the latest account for an owner and account type + // GetLatestByOwnerAddressAndType gets the latest account for an owner and account type. + // Regardless if more than 1 account for the given type can exist, only the record with + // the largest index is returned GetLatestByOwnerAddressAndType(ctx context.Context, address string, accountType commonpb.AccountType) (*Record, error) // GetRelationshipByOwnerAddress gets a relationship account for a given owner. diff --git a/pkg/code/data/account/tests/tests.go b/pkg/code/data/account/tests/tests.go index 2b755dc2..59a2619e 100644 --- a/pkg/code/data/account/tests/tests.go +++ b/pkg/code/data/account/tests/tests.go @@ -271,6 +271,7 @@ func testGetLatestByOwner(t *testing.T, s account.Store) { for i, accountType := range []commonpb.AccountType{ commonpb.AccountType_TEMPORARY_INCOMING, commonpb.AccountType_TEMPORARY_OUTGOING, + commonpb.AccountType_POOL, } { for j := 0; j < 5; j++ { record := &account.Record{ @@ -295,7 +296,7 @@ func testGetLatestByOwner(t *testing.T, s account.Store) { actualByType, err := s.GetLatestByOwnerAddress(ctx, "owner") require.NoError(t, err) - require.Len(t, actualByType, 2) + require.Len(t, actualByType, 3) for i, accountType := range []commonpb.AccountType{ commonpb.AccountType_TEMPORARY_INCOMING, @@ -306,6 +307,13 @@ func testGetLatestByOwner(t *testing.T, s account.Store) { require.Len(t, actual, 1) assert.Equal(t, fmt.Sprintf("token%d4", i), actual[0].TokenAccount) } + + allActual, ok := actualByType[commonpb.AccountType_POOL] + require.True(t, ok) + require.Len(t, allActual, 5) + for i, actual := range allActual { + assert.Equal(t, fmt.Sprintf("token2%d", i), actual.TokenAccount) + } }) } diff --git a/pkg/code/data/balance/memory/store.go b/pkg/code/data/balance/memory/store.go index 826b486d..d6e0337e 100644 --- a/pkg/code/data/balance/memory/store.go +++ b/pkg/code/data/balance/memory/store.go @@ -11,6 +11,7 @@ import ( type store struct { mu sync.Mutex cachedBalanceVersionsByAccount map[string]uint64 + closedAccounts map[string]any externalCheckpointRecords []*balance.ExternalCheckpointRecord last uint64 } @@ -19,6 +20,7 @@ type store struct { func New() balance.Store { return &store{ cachedBalanceVersionsByAccount: make(map[string]uint64), + closedAccounts: make(map[string]any), } } @@ -59,6 +61,28 @@ func (s *store) AdvanceCachedVersion(_ context.Context, account string, currentV return nil } +// CheckNotClosed implements balance.Store.CheckNotClosed +func (s *store) CheckNotClosed(ctx context.Context, account string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.closedAccounts[account]; ok { + return balance.ErrAccountClosed + } + + return nil +} + +// MarkAsClosed implements balance.Store.MarkAsClosed +func (s *store) MarkAsClosed(ctx context.Context, account string) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.closedAccounts[account] = true + + return nil +} + // SaveExternalCheckpoint implements balance.Store.SaveExternalCheckpoint func (s *store) SaveExternalCheckpoint(_ context.Context, data *balance.ExternalCheckpointRecord) error { if err := data.Validate(); err != nil { @@ -128,6 +152,7 @@ func (s *store) reset() { defer s.mu.Unlock() s.cachedBalanceVersionsByAccount = make(map[string]uint64) + s.closedAccounts = make(map[string]any) s.externalCheckpointRecords = nil s.last = 0 } diff --git a/pkg/code/data/balance/postgres/model.go b/pkg/code/data/balance/postgres/model.go index 514779ab..2764d97b 100644 --- a/pkg/code/data/balance/postgres/model.go +++ b/pkg/code/data/balance/postgres/model.go @@ -3,17 +3,18 @@ package postgres import ( "context" "database/sql" + "errors" "time" "github.com/jmoiron/sqlx" "github.com/code-payments/code-server/pkg/code/data/balance" - pg "github.com/code-payments/code-server/pkg/database/postgres" pgutil "github.com/code-payments/code-server/pkg/database/postgres" ) const ( cachedBalanceVersionTableName = "codewallet__core_cachedbalanceversion" + openCloseLocksTableName = "codewallet__core_opencloselocks" externalCheckpointTableName = "codewallet__core_externalbalancecheckpoint" ) @@ -29,45 +30,106 @@ type externalCheckpointModel struct { func dbGetCachedVersion(ctx context.Context, db *sqlx.DB, account string) (uint64, error) { var res uint64 - query := `SELECT version FROM ` + cachedBalanceVersionTableName + ` - WHERE token_account = $1` - err := db.GetContext(ctx, &res, query, account) - if pg.IsNoRows(err) { - return 0, nil - } else if err != nil { - return 0, err - } - return res, nil + err := pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { + insertQuery := `INSERT INTO ` + cachedBalanceVersionTableName + ` + (token_account, version) + VALUES($1, 0) + ON CONFLICT DO NOTHING + ` + sqlResult, err := tx.ExecContext(ctx, insertQuery, account) + if err != nil { + return err + } + rowsAffected, err := sqlResult.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 1 { + res = 0 + return nil + } + + selectQuery := `SELECT version FROM ` + cachedBalanceVersionTableName + ` + WHERE token_account = $1 + FOR UPDATE` + return db.GetContext(ctx, &res, selectQuery, account) + }) + return res, err + } func dbAdvanceCachedVersion(ctx context.Context, db *sqlx.DB, account string, currentVersion uint64) error { return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { - query := `INSERT INTO ` + cachedBalanceVersionTableName + ` - (token_account, version) - VALUES ($1, 1) + var res uint64 + query := `UPDATE ` + cachedBalanceVersionTableName + ` + SET version = version + 1 + WHERE token_account = $1 AND version = $2 RETURNING version ` - params := []any{account} - if currentVersion > 0 { - query = `UPDATE ` + cachedBalanceVersionTableName + ` - SET version = version + 1 - WHERE token_account = $1 AND version = $2 - RETURNING version - ` - params = append(params, currentVersion) + err := tx.GetContext(ctx, &res, query, account, currentVersion) + if pgutil.IsNoRows(err) || pgutil.IsUniqueViolation(err) { + return balance.ErrStaleCachedBalanceVersion + } + if err != nil { + return err } + return nil + }) - var res uint64 - err := tx.GetContext(ctx, &res, query, params...) - if pg.IsNoRows(err) || pg.IsUniqueViolation(err) { - return balance.ErrStaleCachedBalanceVersion +} + +func dbCheckNotClosed(ctx context.Context, db *sqlx.DB, account string) error { + return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { + insertQuery := `INSERT INTO ` + openCloseLocksTableName + ` + (token_account, is_open) + VALUES ($1, TRUE) + ON CONFLICT DO NOTHING + ` + + _, err := tx.ExecContext(ctx, insertQuery, account) + if err != nil { + return err } + + selectQuery := `SELECT is_open FROM ` + openCloseLocksTableName + ` + WHERE token_account = $1 + FOR UPDATE + ` + var isOpen bool + err = tx.GetContext(ctx, &isOpen, selectQuery, account) if err != nil { return err } + if !isOpen { + return balance.ErrAccountClosed + } return nil }) +} +func dbMarkAsClosed(ctx context.Context, db *sqlx.DB, account string) error { + return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { + query := `INSERT INTO ` + openCloseLocksTableName + ` + (token_account, is_open) + VALUES ($1, FALSE) + + ON CONFLICT (token_account) + DO UPDATE + SET is_open = FALSE + WHERE ` + openCloseLocksTableName + `.token_account = $1 + + RETURNING is_open + ` + var isOpen bool + err := tx.GetContext(ctx, &isOpen, query, account) + if err != nil { + return err + } + if isOpen { + return errors.New("unexpected state transition") + } + return nil + }) } func toExternalCheckpointModel(obj *balance.ExternalCheckpointRecord) (*externalCheckpointModel, error) { diff --git a/pkg/code/data/balance/postgres/store.go b/pkg/code/data/balance/postgres/store.go index 0ce42546..2d57a63e 100644 --- a/pkg/code/data/balance/postgres/store.go +++ b/pkg/code/data/balance/postgres/store.go @@ -30,6 +30,16 @@ func (s *store) AdvanceCachedVersion(ctx context.Context, account string, curren return dbAdvanceCachedVersion(ctx, s.db, account, currentVersion) } +// CheckNotClosed implements balance.Store.CheckNotClosed +func (s *store) CheckNotClosed(ctx context.Context, account string) error { + return dbCheckNotClosed(ctx, s.db, account) +} + +// MarkAsClosed implements balance.Store.MarkAsClosed +func (s *store) MarkAsClosed(ctx context.Context, account string) error { + return dbMarkAsClosed(ctx, s.db, account) +} + // SaveExternalCheckpoint implements balance.Store.SaveExternalCheckpoint func (s *store) SaveExternalCheckpoint(ctx context.Context, record *balance.ExternalCheckpointRecord) error { model, err := toExternalCheckpointModel(record) diff --git a/pkg/code/data/balance/postgres/store_test.go b/pkg/code/data/balance/postgres/store_test.go index 862b330f..b0d19c5c 100644 --- a/pkg/code/data/balance/postgres/store_test.go +++ b/pkg/code/data/balance/postgres/store_test.go @@ -33,6 +33,15 @@ const ( CONSTRAINT codewallet__core_cachedbalanceversion__unique__token_account UNIQUE (token_account) ); + CREATE TABLE codewallet__core_opencloselocks ( + id SERIAL NOT NULL PRIMARY KEY, + + token_account TEXT NOT NULL, + is_open BOOL NOT NULL, + + CONSTRAINT codewallet__core_opencloselocks__unique__token_account UNIQUE (token_account) + ); + CREATE TABLE codewallet__core_externalbalancecheckpoint ( id SERIAL NOT NULL PRIMARY KEY, @@ -49,6 +58,7 @@ const ( // Used for testing ONLY, the table and migrations are external to this repository tableDestroy = ` DROP TABLE codewallet__core_cachedbalanceversion; + DROP TABLE codewallet__core_opencloselocks; DROP TABLE codewallet__core_externalbalancecheckpoint; ` ) diff --git a/pkg/code/data/balance/store.go b/pkg/code/data/balance/store.go index 52f0cd9e..cd80428f 100644 --- a/pkg/code/data/balance/store.go +++ b/pkg/code/data/balance/store.go @@ -8,6 +8,8 @@ import ( var ( ErrStaleCachedBalanceVersion = errors.New("cached balance version is stale") + ErrAccountClosed = errors.New("account open state is stale") + ErrCheckpointNotFound = errors.New("checkpoint not found") ErrStaleCheckpoint = errors.New("checkpoint is stale") ) @@ -18,15 +20,29 @@ type Store interface { GetCachedVersion(ctx context.Context, account string) (uint64, error) // AdvanceCachedVersion advances an account's cached balance version. + // // ErrStaleCachedBalanceVersion is returned if the currentVersion is out of date. AdvanceCachedVersion(ctx context.Context, account string, currentVersion uint64) error + // CheckNotClosed checks whether an account is closed under a lock to guarantee + // payments to a closeable destination with cached balances are made to an open + // account. + // + // ErrAccountClosed is returned if the account has been closed. + CheckNotClosed(ctx context.Context, account string) error + + // MarkAsClosed marks an account as being closed and unable to receive payments + // as a destination. + MarkAsClosed(ctx context.Context, account string) error + // SaveExternalCheckpoint saves an external balance at a checkpoint. + // // ErrStaleCheckpoint is returned if the checkpoint is outdated SaveExternalCheckpoint(ctx context.Context, record *ExternalCheckpointRecord) error // GetExternalCheckpoint gets an exeternal balance checkpoint for a - // given account. ErrCheckpointNotFound is returend if no DB record - // exists. + // given account. + // + // ErrCheckpointNotFound is returend if no DB record exists. GetExternalCheckpoint(ctx context.Context, account string) (*ExternalCheckpointRecord, error) } diff --git a/pkg/code/data/balance/tests/tests.go b/pkg/code/data/balance/tests/tests.go index d50240d9..498df834 100644 --- a/pkg/code/data/balance/tests/tests.go +++ b/pkg/code/data/balance/tests/tests.go @@ -14,6 +14,7 @@ import ( func RunTests(t *testing.T, s balance.Store, teardown func()) { for _, tf := range []func(t *testing.T, s balance.Store){ testCachedBalanceVersionHappyPath, + testClosedAccountHappyPath, testExternalCheckpointHappyPath, } { tf(t, s) @@ -26,16 +27,18 @@ func testCachedBalanceVersionHappyPath(t *testing.T, s balance.Store) { ctx := context.Background() for i := range 100 { + for j := 0; j < 10; j++ { + currentVersion, err := s.GetCachedVersion(ctx, "token_account_1") + require.NoError(t, err) + assert.EqualValues(t, i, currentVersion) + } + if i > 0 { assert.Equal(t, balance.ErrStaleCachedBalanceVersion, s.AdvanceCachedVersion(ctx, "token_account_1", uint64(i-1))) } assert.Equal(t, balance.ErrStaleCachedBalanceVersion, s.AdvanceCachedVersion(ctx, "token_account_1", uint64(i+1))) - currentVersion, err := s.GetCachedVersion(ctx, "token_account_1") - require.NoError(t, err) - assert.EqualValues(t, i, currentVersion) - - require.NoError(t, s.AdvanceCachedVersion(ctx, "token_account_1", currentVersion)) + require.NoError(t, s.AdvanceCachedVersion(ctx, "token_account_1", uint64(i))) } currentVersion, err := s.GetCachedVersion(ctx, "token_account_2") @@ -44,6 +47,19 @@ func testCachedBalanceVersionHappyPath(t *testing.T, s balance.Store) { }) } +func testClosedAccountHappyPath(t *testing.T, s balance.Store) { + t.Run("testClosedAccountHappyPath", func(t *testing.T) { + ctx := context.Background() + + require.NoError(t, s.CheckNotClosed(ctx, "token_account_1")) + + require.NoError(t, s.MarkAsClosed(ctx, "token_account_1")) + + assert.Equal(t, balance.ErrAccountClosed, s.CheckNotClosed(ctx, "token_account_1")) + require.NoError(t, s.CheckNotClosed(ctx, "token_account_2s")) + }) +} + func testExternalCheckpointHappyPath(t *testing.T, s balance.Store) { t.Run("testExternalCheckpointHappyPath", func(t *testing.T) { ctx := context.Background() diff --git a/pkg/code/data/intent/intent.go b/pkg/code/data/intent/intent.go index 5c4c04a9..c43bbf38 100644 --- a/pkg/code/data/intent/intent.go +++ b/pkg/code/data/intent/intent.go @@ -33,6 +33,7 @@ const ( ReceivePaymentsPublicly EstablishRelationship // Deprecated privacy flow Login // Deprecated login flow + PublicDistribution ) type Record struct { @@ -47,6 +48,7 @@ type Record struct { ExternalDepositMetadata *ExternalDepositMetadata SendPublicPaymentMetadata *SendPublicPaymentMetadata ReceivePaymentsPubliclyMetadata *ReceivePaymentsPubliclyMetadata + PublicDistributionMetadata *PublicDistributionMetadata ExtendedMetadata []byte @@ -58,7 +60,7 @@ type Record struct { } type OpenAccountsMetadata struct { - // Nothing yet + // todo: What should be stored here given different flows? } type ExternalDepositMetadata struct { @@ -96,6 +98,12 @@ type ReceivePaymentsPubliclyMetadata struct { UsdMarketValue float64 } +type PublicDistributionMetadata struct { + Source string + Quantity uint64 + UsdMarketValue float64 +} + func (r *Record) IsCompleted() bool { return r.State == StateConfirmed } @@ -125,6 +133,12 @@ func (r *Record) Clone() Record { receivePaymentsPubliclyMetadata = &cloned } + var publicDistributionMetadata *PublicDistributionMetadata + if r.PublicDistributionMetadata != nil { + cloned := r.PublicDistributionMetadata.Clone() + publicDistributionMetadata = &cloned + } + return Record{ Id: r.Id, @@ -137,6 +151,7 @@ func (r *Record) Clone() Record { ExternalDepositMetadata: externalDepositMetadata, SendPublicPaymentMetadata: sendPublicPaymentMetadata, ReceivePaymentsPubliclyMetadata: receivePaymentsPubliclyMetadata, + PublicDistributionMetadata: publicDistributionMetadata, ExtendedMetadata: r.ExtendedMetadata, @@ -160,6 +175,7 @@ func (r *Record) CopyTo(dst *Record) { dst.ExternalDepositMetadata = r.ExternalDepositMetadata dst.SendPublicPaymentMetadata = r.SendPublicPaymentMetadata dst.ReceivePaymentsPubliclyMetadata = r.ReceivePaymentsPubliclyMetadata + dst.PublicDistributionMetadata = r.PublicDistributionMetadata dst.ExtendedMetadata = r.ExtendedMetadata @@ -227,6 +243,17 @@ func (r *Record) Validate() error { } } + if r.IntentType == PublicDistribution { + if r.PublicDistributionMetadata == nil { + return errors.New("public distribution metadata must be present") + } + + err := r.PublicDistributionMetadata.Validate() + if err != nil { + return err + } + } + return nil } @@ -388,6 +415,32 @@ func (m *ReceivePaymentsPubliclyMetadata) Validate() error { return nil } +func (m *PublicDistributionMetadata) Clone() PublicDistributionMetadata { + return PublicDistributionMetadata{ + Source: m.Source, + Quantity: m.Quantity, + UsdMarketValue: m.UsdMarketValue, + } +} + +func (m *PublicDistributionMetadata) CopyTo(dst *PublicDistributionMetadata) { + dst.Source = m.Source + dst.Quantity = m.Quantity + dst.UsdMarketValue = m.UsdMarketValue +} + +func (m *PublicDistributionMetadata) Validate() error { + if len(m.Source) == 0 { + return errors.New("source is required") + } + + if m.Quantity == 0 { + return errors.New("quantity is required") + } + + return nil +} + func (s State) IsTerminal() bool { switch s { case StateConfirmed: @@ -439,6 +492,8 @@ func (t Type) String() string { return "establish_relationship" case Login: return "login" + case PublicDistribution: + return "public_distribution" } return "unknown" diff --git a/pkg/code/data/intent/memory/store.go b/pkg/code/data/intent/memory/store.go index 46cda81b..34710406 100644 --- a/pkg/code/data/intent/memory/store.go +++ b/pkg/code/data/intent/memory/store.go @@ -110,22 +110,6 @@ func (s *store) findBySource(source string) []*intent.Record { return res } -func (s *store) findByInitiatorAndType(intentType intent.Type, owner string) []*intent.Record { - res := make([]*intent.Record, 0) - for _, item := range s.records { - if item.IntentType != intentType { - continue - } - - if item.InitiatorOwnerAccount != owner { - continue - } - - res = append(res, item) - } - return res -} - func (s *store) findByOwnerSinceTimestamp(owner string, since time.Time) []*intent.Record { res := make([]*intent.Record, 0) for _, item := range s.records { @@ -318,25 +302,6 @@ func (s *store) GetAllByOwner(ctx context.Context, owner string, cursor query.Cu return nil, intent.ErrIntentNotFound } -func (s *store) GetLatestByInitiatorAndType(ctx context.Context, intentType intent.Type, owner string) (*intent.Record, error) { - s.mu.Lock() - defer s.mu.Unlock() - - items := s.findByInitiatorAndType(intentType, owner) - if len(items) == 0 { - return nil, intent.ErrIntentNotFound - } - - latest := items[0] - for _, item := range items { - if item.CreatedAt.After(latest.CreatedAt) { - latest = item - } - } - - return latest, nil -} - func (s *store) GetOriginalGiftCardIssuedIntent(ctx context.Context, giftCardVault string) (*intent.Record, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/pkg/code/data/intent/postgres/model.go b/pkg/code/data/intent/postgres/model.go index 7e88f41b..534dd042 100644 --- a/pkg/code/data/intent/postgres/model.go +++ b/pkg/code/data/intent/postgres/model.go @@ -97,6 +97,10 @@ func toIntentModel(obj *intent.Record) (*intentModel, error) { m.NativeAmount = obj.ReceivePaymentsPubliclyMetadata.OriginalNativeAmount m.UsdMarketValue = obj.ReceivePaymentsPubliclyMetadata.UsdMarketValue + case intent.PublicDistribution: + m.Source = obj.PublicDistributionMetadata.Source + m.Quantity = obj.PublicDistributionMetadata.Quantity + m.UsdMarketValue = obj.PublicDistributionMetadata.UsdMarketValue default: return nil, errors.New("unsupported intent type") } @@ -152,6 +156,12 @@ func fromIntentModel(obj *intentModel) *intent.Record { OriginalExchangeRate: obj.ExchangeRate, OriginalNativeAmount: obj.NativeAmount, + UsdMarketValue: obj.UsdMarketValue, + } + case intent.PublicDistribution: + record.PublicDistributionMetadata = &intent.PublicDistributionMetadata{ + Source: obj.Source, + Quantity: obj.Quantity, UsdMarketValue: obj.UsdMarketValue, } } @@ -241,22 +251,6 @@ func dbGetAllByOwner(ctx context.Context, db *sqlx.DB, owner string, cursor q.Cu return res, nil } -func dbGetLatestByInitiatorAndType(ctx context.Context, db *sqlx.DB, intentType intent.Type, owner string) (*intentModel, error) { - res := &intentModel{} - - query := `SELECT id, intent_id, intent_type, owner, source, destination_owner, destination, quantity, exchange_currency, exchange_rate, native_amount, usd_market_value, is_withdraw, is_deposit, is_remote_send, is_returned, is_issuer_voiding_gift_card, is_micro_payment, extended_metadata, state, version, created_at - FROM ` + intentTableName + ` - WHERE owner = $1 AND intent_type = $2 - ORDER BY created_at DESC - LIMIT 1` - - err := db.GetContext(ctx, res, query, owner, intentType) - if err != nil { - return nil, pgutil.CheckNoRows(err, intent.ErrIntentNotFound) - } - return res, nil -} - func dbGetOriginalGiftCardIssuedIntent(ctx context.Context, db *sqlx.DB, giftCardVault string) (*intentModel, error) { res := []*intentModel{} diff --git a/pkg/code/data/intent/postgres/store.go b/pkg/code/data/intent/postgres/store.go index d1b7b658..83c793b4 100644 --- a/pkg/code/data/intent/postgres/store.go +++ b/pkg/code/data/intent/postgres/store.go @@ -68,18 +68,6 @@ func (s *store) GetAllByOwner(ctx context.Context, owner string, cursor query.Cu return intents, nil } -// GetLatestByInitiatorAndType gets the latest record by intent type and initiating owner -// -// Returns ErrNotFound if no records are found. -func (s *store) GetLatestByInitiatorAndType(ctx context.Context, intentType intent.Type, owner string) (*intent.Record, error) { - model, err := dbGetLatestByInitiatorAndType(ctx, s.db, intentType, owner) - if err != nil { - return nil, err - } - - return fromIntentModel(model), nil -} - // GetOriginalGiftCardIssuedIntent gets the original intent where a gift card // was issued by its vault address. func (s *store) GetOriginalGiftCardIssuedIntent(ctx context.Context, giftCardVault string) (*intent.Record, error) { diff --git a/pkg/code/data/intent/store.go b/pkg/code/data/intent/store.go index 083b9327..27dda556 100644 --- a/pkg/code/data/intent/store.go +++ b/pkg/code/data/intent/store.go @@ -28,11 +28,6 @@ type Store interface { // Returns ErrNotFound if no records are found. GetAllByOwner(ctx context.Context, owner string, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*Record, error) - // GetLatestByInitiatorAndType gets the latest record by initiating owner and intent type - // - // Returns ErrNotFound if no records are found. - GetLatestByInitiatorAndType(ctx context.Context, intentType Type, owner string) (*Record, error) - // GetOriginalGiftCardIssuedIntent gets the original intent where a gift card // was issued by its vault address. GetOriginalGiftCardIssuedIntent(ctx context.Context, giftCardVault string) (*Record, error) diff --git a/pkg/code/data/intent/tests/tests.go b/pkg/code/data/intent/tests/tests.go index 70247660..bc30441d 100644 --- a/pkg/code/data/intent/tests/tests.go +++ b/pkg/code/data/intent/tests/tests.go @@ -18,9 +18,9 @@ func RunTests(t *testing.T, s intent.Store, teardown func()) { testExternalDepositRoundTrip, testSendPublicPaymentRoundTrip, testReceivePaymentsPubliclyRoundTrip, + testPublicDistributionRoundTrip, testUpdateHappyPath, testUpdateStaleRecord, - testGetLatestByInitiatorAndType, testGetOriginalGiftCardIssuedIntent, testGetGiftCardClaimedIntent, testGetTransactedAmountForAntiMoneyLaundering, @@ -229,6 +229,51 @@ func testReceivePaymentsPubliclyRoundTrip(t *testing.T, s intent.Store) { }) } +func testPublicDistributionRoundTrip(t *testing.T, s intent.Store) { + t.Run("testPublicDistributionRoundTrip", func(t *testing.T) { + ctx := context.Background() + + actual, err := s.Get(ctx, "test_intent_id") + require.Error(t, err) + assert.Equal(t, intent.ErrIntentNotFound, err) + assert.Nil(t, actual) + + expected := intent.Record{ + IntentId: "test_intent_id", + IntentType: intent.PublicDistribution, + InitiatorOwnerAccount: "test_owner", + PublicDistributionMetadata: &intent.PublicDistributionMetadata{ + Source: "test_source", + Quantity: 12345, + UsdMarketValue: 999.99, + }, + ExtendedMetadata: []byte("extended_metadata"), + State: intent.StateUnknown, + CreatedAt: time.Now(), + } + cloned := expected.Clone() + err = s.Save(ctx, &expected) + require.NoError(t, err) + assert.EqualValues(t, 1, expected.Id) + assert.EqualValues(t, 1, expected.Version) + + actual, err = s.Get(ctx, "test_intent_id") + require.NoError(t, err) + assert.Equal(t, cloned.IntentId, actual.IntentId) + assert.Equal(t, cloned.IntentType, actual.IntentType) + assert.Equal(t, cloned.InitiatorOwnerAccount, actual.InitiatorOwnerAccount) + require.NotNil(t, actual.PublicDistributionMetadata) + assert.Equal(t, cloned.PublicDistributionMetadata.Source, actual.PublicDistributionMetadata.Source) + assert.Equal(t, cloned.PublicDistributionMetadata.Quantity, actual.PublicDistributionMetadata.Quantity) + assert.Equal(t, cloned.PublicDistributionMetadata.UsdMarketValue, actual.PublicDistributionMetadata.UsdMarketValue) + assert.Equal(t, cloned.ExtendedMetadata, actual.ExtendedMetadata) + assert.Equal(t, cloned.State, actual.State) + assert.Equal(t, cloned.CreatedAt.Unix(), actual.CreatedAt.Unix()) + assert.EqualValues(t, 1, actual.Id) + assert.EqualValues(t, 1, actual.Version) + }) +} + func testUpdateHappyPath(t *testing.T, s intent.Store) { t.Run("testUpdateHappyPath", func(t *testing.T) { ctx := context.Background() @@ -295,33 +340,6 @@ func testUpdateStaleRecord(t *testing.T, s intent.Store) { }) } -func testGetLatestByInitiatorAndType(t *testing.T, s intent.Store) { - ctx := context.Background() - - t.Run("testGetLatestByInitiatorAndType", func(t *testing.T) { - records := []intent.Record{ - {IntentId: "t1", IntentType: intent.OpenAccounts, InitiatorOwnerAccount: "o1", OpenAccountsMetadata: &intent.OpenAccountsMetadata{}, State: intent.StatePending}, - {IntentId: "t2", IntentType: intent.OpenAccounts, InitiatorOwnerAccount: "o1", OpenAccountsMetadata: &intent.OpenAccountsMetadata{}, State: intent.StateFailed}, - {IntentId: "t3", IntentType: intent.OpenAccounts, InitiatorOwnerAccount: "o1", OpenAccountsMetadata: &intent.OpenAccountsMetadata{}, State: intent.StateUnknown}, - {IntentId: "t4", IntentType: intent.OpenAccounts, InitiatorOwnerAccount: "o1", OpenAccountsMetadata: &intent.OpenAccountsMetadata{}, State: intent.StateUnknown}, - {IntentId: "t5", IntentType: intent.OpenAccounts, InitiatorOwnerAccount: "o2", OpenAccountsMetadata: &intent.OpenAccountsMetadata{}, State: intent.StateUnknown}, - {IntentId: "t6", IntentType: intent.OpenAccounts, InitiatorOwnerAccount: "o2", OpenAccountsMetadata: &intent.OpenAccountsMetadata{}, State: intent.StateFailed}, - } - for i, record := range records { - record.CreatedAt = time.Now().Add(time.Duration(i) * time.Second) - require.NoError(t, s.Save(ctx, &record)) - } - - _, err := s.GetLatestByInitiatorAndType(ctx, intent.SendPublicPayment, "o1") - assert.Equal(t, intent.ErrIntentNotFound, err) - - actual, err := s.GetLatestByInitiatorAndType(ctx, intent.OpenAccounts, "o1") - require.NoError(t, err) - assert.Equal(t, "t4", actual.IntentId) - }) - -} - func testGetOriginalGiftCardIssuedIntent(t *testing.T, s intent.Store) { t.Run("testGetOriginalGiftCardIssuedIntent", func(t *testing.T) { ctx := context.Background() diff --git a/pkg/code/data/internal.go b/pkg/code/data/internal.go index 62e6fdd7..3e3616cd 100644 --- a/pkg/code/data/internal.go +++ b/pkg/code/data/internal.go @@ -121,6 +121,8 @@ type DatabaseData interface { // -------------------------------------------------------------------------------- GetCachedBalanceVersion(ctx context.Context, account string) (uint64, error) AdvanceCachedBalanceVersion(ctx context.Context, account string, currentVersion uint64) error + CheckNotClosedForBalanceUpdate(ctx context.Context, account string) error + MarkAsClosedForBalanceUpdate(ctx context.Context, account string) error SaveExternalBalanceCheckpoint(ctx context.Context, record *balance.ExternalCheckpointRecord) error GetExternalBalanceCheckpoint(ctx context.Context, account string) (*balance.ExternalCheckpointRecord, error) @@ -185,7 +187,6 @@ type DatabaseData interface { GetIntent(ctx context.Context, intentID string) (*intent.Record, error) GetIntentBySignature(ctx context.Context, signature string) (*intent.Record, error) GetAllIntentsByOwner(ctx context.Context, owner string, opts ...query.Option) ([]*intent.Record, error) - GetLatestIntentByInitiatorAndType(ctx context.Context, intentType intent.Type, owner string) (*intent.Record, error) GetOriginalGiftCardIssuedIntent(ctx context.Context, giftCardVault string) (*intent.Record, error) GetGiftCardClaimedIntent(ctx context.Context, giftCardVault string) (*intent.Record, error) GetTransactedAmountForAntiMoneyLaundering(ctx context.Context, owner string, since time.Time) (uint64, float64, error) @@ -436,6 +437,12 @@ func (dp *DatabaseProvider) GetCachedBalanceVersion(ctx context.Context, account func (dp *DatabaseProvider) AdvanceCachedBalanceVersion(ctx context.Context, account string, currentVersion uint64) error { return dp.balance.AdvanceCachedVersion(ctx, account, currentVersion) } +func (dp *DatabaseProvider) CheckNotClosedForBalanceUpdate(ctx context.Context, account string) error { + return dp.balance.CheckNotClosed(ctx, account) +} +func (dp *DatabaseProvider) MarkAsClosedForBalanceUpdate(ctx context.Context, account string) error { + return dp.balance.MarkAsClosed(ctx, account) +} func (dp *DatabaseProvider) SaveExternalBalanceCheckpoint(ctx context.Context, record *balance.ExternalCheckpointRecord) error { return dp.balance.SaveExternalCheckpoint(ctx, record) } @@ -649,9 +656,6 @@ func (dp *DatabaseProvider) GetAllIntentsByOwner(ctx context.Context, owner stri return dp.intents.GetAllByOwner(ctx, owner, req.Cursor, req.Limit, req.SortBy) } -func (dp *DatabaseProvider) GetLatestIntentByInitiatorAndType(ctx context.Context, intentType intent.Type, owner string) (*intent.Record, error) { - return dp.intents.GetLatestByInitiatorAndType(ctx, intentType, owner) -} func (dp *DatabaseProvider) GetOriginalGiftCardIssuedIntent(ctx context.Context, giftCardVault string) (*intent.Record, error) { return dp.intents.GetOriginalGiftCardIssuedIntent(ctx, giftCardVault) } diff --git a/pkg/code/server/account/server.go b/pkg/code/server/account/server.go index f1b37070..6ced7b34 100644 --- a/pkg/code/server/account/server.go +++ b/pkg/code/server/account/server.go @@ -68,14 +68,24 @@ func (s *server) IsCodeAccount(ctx context.Context, req *accountpb.IsCodeAccount return nil, err } - state, err := common.GetOwnerManagementState(ctx, s.data, owner) - if err != nil { + ownerMetadata, err := common.GetOwnerMetadata(ctx, s.data, owner) + if err == common.ErrOwnerNotFound { + return &accountpb.IsCodeAccountResponse{ + Result: accountpb.IsCodeAccountResponse_NOT_FOUND, + }, nil + } else if err != nil { log.WithError(err).Warn("failure getting owner management state") return nil, status.Error(codes.Internal, "") } + if ownerMetadata.Type != common.OwnerTypeUser12Words { + return &accountpb.IsCodeAccountResponse{ + Result: accountpb.IsCodeAccountResponse_NOT_FOUND, + }, nil + } + var result accountpb.IsCodeAccountResponse_Result - switch state { + switch ownerMetadata.State { case common.OwnerManagementStateCodeAccount: result = accountpb.IsCodeAccountResponse_OK case common.OwnerManagementStateNotFound: @@ -150,6 +160,28 @@ func (s *server) GetTokenAccountInfos(ctx context.Context, req *accountpb.GetTok return nil, status.Error(codes.Internal, "") } + nextPoolIndex := len(recordsByType[commonpb.AccountType_POOL]) + + // Filter out account records for accounts that have completed their full + // lifecycle + // + // todo: This needs tests + for accountType, batchRecords := range recordsByType { + switch accountType { + case commonpb.AccountType_POOL: + default: + continue + } + + var filtered []*common.AccountRecords + for _, records := range batchRecords { + if records.IsTimelock() && !records.Timelock.IsClosed() { + filtered = append(filtered, records) + } + } + recordsByType[accountType] = filtered + } + // Trigger a deposit sync with the blockchain for the primary account, if it exists if primaryRecords, ok := recordsByType[commonpb.AccountType_PRIMARY]; ok { if !primaryRecords[0].General.RequiresDepositSync { @@ -193,6 +225,7 @@ func (s *server) GetTokenAccountInfos(ctx context.Context, req *accountpb.GetTok resp := &accountpb.GetTokenAccountInfosResponse{ Result: accountpb.GetTokenAccountInfosResponse_OK, TokenAccountInfos: tokenAccountInfos, + NextPoolIndex: uint64(nextPoolIndex), } // Is this a gift card in a terminal state that we can cache? diff --git a/pkg/code/server/account/server_test.go b/pkg/code/server/account/server_test.go index 2d76fbf9..0b59e3b9 100644 --- a/pkg/code/server/account/server_test.go +++ b/pkg/code/server/account/server_test.go @@ -152,13 +152,19 @@ func TestGetTokenAccountInfos_UserAccounts_HappyPath(t *testing.T) { bucketDerivedOwner := testutil.NewRandomAccount(t) tempIncomingDerivedOwner := testutil.NewRandomAccount(t) swapDerivedOwner := testutil.NewRandomAccount(t) + poolDerivedOwner1 := testutil.NewRandomAccount(t) + poolDerivedOwner2 := testutil.NewRandomAccount(t) primaryAccountRecords := setupAccountRecords(t, env, ownerAccount, ownerAccount, 0, commonpb.AccountType_PRIMARY) bucketAccountRecords := setupAccountRecords(t, env, ownerAccount, bucketDerivedOwner, 0, commonpb.AccountType_BUCKET_100_KIN) setupAccountRecords(t, env, ownerAccount, swapDerivedOwner, 0, commonpb.AccountType_SWAP) setupAccountRecords(t, env, ownerAccount, tempIncomingDerivedOwner, 2, commonpb.AccountType_TEMPORARY_INCOMING) + pool1AccountRecords := setupAccountRecords(t, env, ownerAccount, poolDerivedOwner1, 0, commonpb.AccountType_POOL) + pool2AccountRecords := setupAccountRecords(t, env, ownerAccount, poolDerivedOwner2, 1, commonpb.AccountType_POOL) setupCachedBalance(t, env, bucketAccountRecords, common.ToCoreMintQuarks(100)) setupCachedBalance(t, env, primaryAccountRecords, common.ToCoreMintQuarks(42)) + setupCachedBalance(t, env, pool1AccountRecords, common.ToCoreMintQuarks(88)) + setupCachedBalance(t, env, pool2AccountRecords, common.ToCoreMintQuarks(123)) otherOwnerAccount := testutil.NewRandomAccount(t) setupAccountRecords(t, env, otherOwnerAccount, otherOwnerAccount, 0, commonpb.AccountType_PRIMARY) @@ -168,7 +174,8 @@ func TestGetTokenAccountInfos_UserAccounts_HappyPath(t *testing.T) { resp, err := env.client.GetTokenAccountInfos(env.ctx, req) require.NoError(t, err) assert.Equal(t, accountpb.GetTokenAccountInfosResponse_OK, resp.Result) - assert.Len(t, resp.TokenAccountInfos, 4) + assert.Len(t, resp.TokenAccountInfos, 6) + assert.EqualValues(t, 2, resp.NextPoolIndex) for _, authority := range []*common.Account{ ownerAccount, @@ -210,6 +217,14 @@ func TestGetTokenAccountInfos_UserAccounts_HappyPath(t *testing.T) { assert.Equal(t, commonpb.AccountType_SWAP, accountInfo.AccountType) assert.EqualValues(t, 0, accountInfo.Index) assert.EqualValues(t, 0, accountInfo.Balance) + case poolDerivedOwner1.PublicKey().ToBase58(): + assert.Equal(t, commonpb.AccountType_POOL, accountInfo.AccountType) + assert.EqualValues(t, 0, accountInfo.Index) + assert.EqualValues(t, common.ToCoreMintQuarks(88), accountInfo.Balance) + case poolDerivedOwner2.PublicKey().ToBase58(): + assert.Equal(t, commonpb.AccountType_POOL, accountInfo.AccountType) + assert.EqualValues(t, 1, accountInfo.Index) + assert.EqualValues(t, common.ToCoreMintQuarks(123), accountInfo.Balance) default: require.Fail(t, "unexpected authority") } @@ -465,6 +480,7 @@ func TestGetTokenAccountInfos_RemoteSendGiftCard_HappyPath(t *testing.T) { require.NoError(t, err) assert.Equal(t, accountpb.GetTokenAccountInfosResponse_OK, resp.Result) assert.Len(t, resp.TokenAccountInfos, 1) + assert.EqualValues(t, 0, resp.NextPoolIndex) accountInfo, ok := resp.TokenAccountInfos[timelockAccounts.Vault.PublicKey().ToBase58()] require.True(t, ok) diff --git a/pkg/code/server/transaction/airdrop.go b/pkg/code/server/transaction/airdrop.go index a0e1d809..5cea6e9a 100644 --- a/pkg/code/server/transaction/airdrop.go +++ b/pkg/code/server/transaction/airdrop.go @@ -399,7 +399,7 @@ func (s *transactionServer) airdrop(ctx context.Context, intentId string, owner return err } - err = balanceLock.OnCommit(ctx, s.data) + err = balanceLock.OnNewBalanceVersion(ctx, s.data) if err != nil { return err } diff --git a/pkg/code/server/transaction/errors.go b/pkg/code/server/transaction/errors.go index ce245895..6f6fb7bf 100644 --- a/pkg/code/server/transaction/errors.go +++ b/pkg/code/server/transaction/errors.go @@ -23,9 +23,9 @@ const ( var ( ErrTimedOutReceivingRequest = errors.New("timed out receiving request") - ErrTooManyPayments = newIntentDeniedError("too many payments") - ErrTransactionLimitExceeded = newIntentDeniedError("dollar value exceeds limit") - ErrNotManagedByCode = newIntentDeniedError("at least one account is no longer managed by code") + ErrTooManyPayments = NewIntentDeniedError("too many payments") + ErrTransactionLimitExceeded = NewIntentDeniedError("dollar value exceeds limit") + ErrNotManagedByCode = NewIntentDeniedError("at least one account is no longer managed by code") ErrInvalidSignature = errors.New("invalid signature provided") ErrMissingSignature = errors.New("at least one signature is missing") @@ -38,22 +38,22 @@ type IntentValidationError struct { message string } -func newIntentValidationError(message string) IntentValidationError { +func NewIntentValidationError(message string) IntentValidationError { return IntentValidationError{ message: message, } } -func newIntentValidationErrorf(format string, args ...any) IntentValidationError { - return newIntentValidationError(fmt.Sprintf(format, args...)) +func NewIntentValidationErrorf(format string, args ...any) IntentValidationError { + return NewIntentValidationError(fmt.Sprintf(format, args...)) } -func newActionValidationError(action *transactionpb.Action, message string) IntentValidationError { - return newIntentValidationError(fmt.Sprintf("actions[%d]: %s", action.Id, message)) +func NewActionValidationError(action *transactionpb.Action, message string) IntentValidationError { + return NewIntentValidationError(fmt.Sprintf("actions[%d]: %s", action.Id, message)) } -func newActionValidationErrorf(action *transactionpb.Action, message string, args ...any) IntentValidationError { - return newActionValidationError(action, fmt.Sprintf(message, args...)) +func NewActionValidationErrorf(action *transactionpb.Action, message string, args ...any) IntentValidationError { + return NewActionValidationError(action, fmt.Sprintf(message, args...)) } func (e IntentValidationError) Error() string { @@ -64,7 +64,7 @@ type IntentDeniedError struct { message string } -func newIntentDeniedError(message string) IntentDeniedError { +func NewIntentDeniedError(message string) IntentDeniedError { return IntentDeniedError{ message: message, } @@ -78,18 +78,18 @@ type StaleStateError struct { message string } -func newStaleStateError(message string) StaleStateError { +func NewStaleStateError(message string) StaleStateError { return StaleStateError{ message: message, } } -func newStaleStateErrorf(format string, args ...any) StaleStateError { - return newStaleStateError(fmt.Sprintf(format, args...)) +func NewStaleStateErrorf(format string, args ...any) StaleStateError { + return NewStaleStateError(fmt.Sprintf(format, args...)) } -func newActionWithStaleStateError(action *transactionpb.Action, message string) StaleStateError { - return newStaleStateError(fmt.Sprintf("actions[%d]: %s", action.Id, message)) +func NewActionWithStaleStateError(action *transactionpb.Action, message string) StaleStateError { + return NewStaleStateError(fmt.Sprintf("actions[%d]: %s", action.Id, message)) } func (e StaleStateError) Error() string { diff --git a/pkg/code/server/transaction/intent.go b/pkg/code/server/transaction/intent.go index 4da7a149..3f902438 100644 --- a/pkg/code/server/transaction/intent.go +++ b/pkg/code/server/transaction/intent.go @@ -37,6 +37,24 @@ import ( "github.com/code-payments/code-server/pkg/solana/token" ) +type SubmitIntentIntegration interface { + // AllowCreation determines whether the new intent creation should be allowed + // with app-specific validation rules + AllowCreation(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) error +} + +type defaultSubmitIntentIntegration struct { +} + +// NewDefaultSubmitIntentIntegration retuns a SubmitIntentIntegration that allows everything +func NewDefaultSubmitIntentIntegration() SubmitIntentIntegration { + return &defaultSubmitIntentIntegration{} +} + +func (i *defaultSubmitIntentIntegration) AllowCreation(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) error { + return nil +} + func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_SubmitIntentServer) error { // Bound the total RPC. Keeping the timeout higher to see where we land because // there's a lot of stuff happening in this method. @@ -92,11 +110,9 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm // Figure out what kind of intent we're operating on and initialize the intent handler var intentHandler CreateIntentHandler - var intentHasNewOwner bool // todo: intent handler should specify this switch submitActionsReq.Metadata.Type.(type) { case *transactionpb.Metadata_OpenAccounts: log = log.WithField("intent_type", "open_accounts") - intentHasNewOwner = true intentHandler = NewOpenAccountsIntentHandler(s.conf, s.data, s.antispamGuard) case *transactionpb.Metadata_SendPublicPayment: log = log.WithField("intent_type", "send_public_payment") @@ -104,6 +120,9 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm case *transactionpb.Metadata_ReceivePaymentsPublicly: log = log.WithField("intent_type", "receive_payments_publicly") intentHandler = NewReceivePaymentsPubliclyIntentHandler(s.conf, s.data, s.antispamGuard, s.amlGuard) + case *transactionpb.Metadata_PublicDistribution: + log = log.WithField("intent_type", "public_distribution") + intentHandler = NewPublicDistributionIntentHandler(s.conf, s.data, s.antispamGuard, s.amlGuard) default: return handleSubmitIntentError(streamer, status.Error(codes.InvalidArgument, "SubmitIntentRequest.SubmitActions.Metadata is nil")) } @@ -117,6 +136,12 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm } log = log.WithField("submit_actions_owner_account", submitActionsOwnerAccount.PublicKey().ToBase58()) + createsNewUserOwner, err := intentHandler.CreatesNewUser(ctx, submitActionsReq.Metadata) + if err != nil { + log.WithError(err).Warn("failure checking if intent creates a new user") + return handleSubmitIntentError(streamer, err) + } + var initiatorOwnerAccount *common.Account submitActionsOwnerMetadata, err := common.GetOwnerMetadata(ctx, s.data, submitActionsOwnerAccount) if err == nil { @@ -141,7 +166,7 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm log.WithError(err).Warn("failure getting user initiator owner account") return handleSubmitIntentError(streamer, err) } else if err == account.ErrAccountInfoNotFound || accountInfoRecord.AccountType != commonpb.AccountType_PRIMARY { - return newActionValidationError(submitActionsReq.Actions[0], "destination must be a primary account") + return NewActionValidationError(submitActionsReq.Actions[0], "destination must be a primary account") } initiatorOwnerAccount, err = common.NewAccountFromPublicKeyString(accountInfoRecord.OwnerAccount) @@ -150,19 +175,19 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm return handleSubmitIntentError(streamer, err) } default: - return newActionValidationError(submitActionsReq.Actions[0], "expected a no privacy withdraw action") + return NewActionValidationError(submitActionsReq.Actions[0], "expected a no privacy withdraw action") } } default: - return newIntentValidationError("expected a receive payments publicly intent") + return NewIntentValidationError("expected a receive payments publicly intent") } default: log.Warnf("unhandled owner account type %s", submitActionsOwnerMetadata.Type) return handleSubmitIntentError(streamer, errors.New("unhandled owner account type")) } } else if err == common.ErrOwnerNotFound { - if !intentHasNewOwner { - return handleSubmitIntentError(streamer, newIntentDeniedError("unexpected owner account")) + if !createsNewUserOwner { + return handleSubmitIntentError(streamer, NewIntentDeniedError("unexpected owner account")) } initiatorOwnerAccount = submitActionsOwnerAccount } else if err != nil { @@ -192,12 +217,12 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm case commonpb.AccountType_REMOTE_SEND_GIFT_CARD: // Remote gift cards are random accounts not owned by a user account's 12 words if !bytes.Equal(typedAction.OpenAccount.Owner.Value, typedAction.OpenAccount.Authority.Value) { - return handleSubmitIntentError(streamer, newActionValidationErrorf(action, "owner must be %s", authorityAccount.PublicKey().ToBase58())) + return handleSubmitIntentError(streamer, NewActionValidationErrorf(action, "owner must be %s", authorityAccount.PublicKey().ToBase58())) } default: // Everything else is owned by a user account's 12 words if !bytes.Equal(typedAction.OpenAccount.Owner.Value, initiatorOwnerAccount.PublicKey().ToBytes()) { - return handleSubmitIntentError(streamer, newActionValidationErrorf(action, "owner must be %s", initiatorOwnerAccount.PublicKey().ToBase58())) + return handleSubmitIntentError(streamer, NewActionValidationErrorf(action, "owner must be %s", initiatorOwnerAccount.PublicKey().ToBase58())) } } @@ -226,7 +251,7 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm // We're operating on a new intent, so validate we don't have an existing DB record if existingIntentRecord != nil { log.Warn("client is attempting to resubmit an intent or reuse an intent id") - return handleSubmitIntentError(streamer, newStaleStateError("intent already exists")) + return handleSubmitIntentError(streamer, NewStaleStateError("intent already exists")) } // Populate metadata into the new DB record @@ -248,29 +273,24 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm return nil } - // Lock any acccounts with outgoing transfers of funds: - // 1. Optimistic version lock at the DB layer to guarantee balance consistency + // Lock any acccounts with fund movement that is not resistent to race conditions + // 1. Global DB layer lock to guarantee balance consistency in a mult-server environment // 2. Local in memory lock to avoid over consumption of local resources (eg. // nonces) when we're likely to encounter a race resulting in DB txn rollback // (eg. mass attempt to claim gift card). - accountBalancesToLock, err := intentHandler.GetAccountsWithBalancesToLock(ctx, intentRecord, submitActionsReq.Metadata) + globalBalanceLocks, err := intentHandler.GetBalanceLocks(ctx, intentRecord, submitActionsReq.Metadata) if err != nil { log.WithError(err).Warn("failure getting accounts with balances to lock") return handleSubmitIntentError(streamer, err) } - localAccountLocks := make([]*sync.Mutex, len(accountBalancesToLock)) - globalBalanceLocks := make([]*balance.OptimisticVersionLock, len(accountBalancesToLock)) - for i, account := range accountBalancesToLock { - log := log.WithField("account", account.PublicKey().ToBase58()) - - localAccountLocks[i] = s.getLocalAccountLock(account) - - globalBalanceLock, err := balance.GetOptimisticVersionLock(ctx, s.data, account) - if err != nil { - log.WithError(err).Warn("failure getting balance lock") - return handleSubmitIntentError(streamer, err) + localAccountLocks := make([]*sync.Mutex, 0) + locallyLockedAccounts := make(map[string]any) + for _, globalBalanceLock := range globalBalanceLocks { + _, ok := locallyLockedAccounts[globalBalanceLock.Account.PublicKey().ToBase58()] + if !ok { + localAccountLocks = append(localAccountLocks, s.getLocalAccountLock(globalBalanceLock.Account)) } - globalBalanceLocks[i] = globalBalanceLock + locallyLockedAccounts[globalBalanceLock.Account.PublicKey().ToBase58()] = true } for _, localAccountLock := range localAccountLocks { localAccountLock.Lock() @@ -293,6 +313,22 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm return handleSubmitIntentError(streamer, err) } + // Validate the new intent with app-specific logic + err = s.submitIntentIntegration.AllowCreation(ctx, intentRecord, submitActionsReq.Metadata, submitActionsReq.Actions) + if err != nil { + switch err.(type) { + case IntentValidationError: + log.WithError(err).Warn("new intent failed integration validation") + case IntentDeniedError: + log.WithError(err).Warn("new intent was denied by integration") + case StaleStateError: + log.WithError(err).Warn("integration detected a client with stale state") + default: + log.WithError(err).Warn("failure checking if new intent was allowed by integration") + } + return handleSubmitIntentError(streamer, err) + } + type fulfillmentWithSigningMetadata struct { record *fulfillment.Record @@ -611,8 +647,8 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm return err } - for _, balanceLock := range globalBalanceLocks { - err = balanceLock.OnCommit(ctx, s.data) + for _, globalBalanceLock := range globalBalanceLocks { + err = globalBalanceLock.CommitFn(ctx, s.data) if err != nil { log.WithError(err).Warn("failure commiting balance update") return err @@ -624,7 +660,7 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm if err != nil { if strings.Contains(err.Error(), "stale") || strings.Contains(err.Error(), "exist") { log.WithError(err).Info("race condition detected") - return handleSubmitIntentError(streamer, newStaleStateErrorf("race detected: %s", err.Error())) + return handleSubmitIntentError(streamer, NewStaleStateErrorf("race detected: %s", err.Error())) } return handleSubmitIntentError(streamer, err) } diff --git a/pkg/code/server/transaction/intent_handler.go b/pkg/code/server/transaction/intent_handler.go index 8e8543bb..f0103fa8 100644 --- a/pkg/code/server/transaction/intent_handler.go +++ b/pkg/code/server/transaction/intent_handler.go @@ -25,8 +25,13 @@ import ( "github.com/code-payments/code-server/pkg/solana" ) -var accountTypesToOpen = []commonpb.AccountType{ - commonpb.AccountType_PRIMARY, +type intentBalanceLock struct { + // The account that's being locked + Account *common.Account + + // The function executed on intent DB commit that is guaranteed to prevent + // race conditions against invalid balance updates + CommitFn func(ctx context.Context, data code_data.Provider) error } // CreateIntentHandler is an interface for handling new intent creations @@ -36,6 +41,10 @@ type CreateIntentHandler interface { // intent should be modified. PopulateMetadata(ctx context.Context, intentRecord *intent.Record, protoMetadata *transactionpb.Metadata) error + // CreatesNewUser returns whether the intent creates a new Code user identified + // via a new owner account + CreatesNewUser(ctx context.Context, metadata *transactionpb.Metadata) (bool, error) + // IsNoop determines whether the intent is a no-op operation. SubmitIntent will // simply return OK and stop any further intent processing. // @@ -44,9 +53,9 @@ type CreateIntentHandler interface { // error. IsNoop(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) (bool, error) - // GetAccountsWithBalancesToLock gets a set of accounts with balances that need - // to be locked. - GetAccountsWithBalancesToLock(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*common.Account, error) + // GetBalanceLocks gets a set of global balance locks to prevent race conditions + // against invalid balance updates that would result in intent fulfillment failure + GetBalanceLocks(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*intentBalanceLock, error) // AllowCreation determines whether the new intent creation should be allowed. AllowCreation(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) error @@ -92,24 +101,54 @@ func (h *OpenAccountsIntentHandler) PopulateMetadata(ctx context.Context, intent return nil } -func (h *OpenAccountsIntentHandler) GetAccountsWithBalancesToLock(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*common.Account, error) { - return nil, nil +func (h *OpenAccountsIntentHandler) CreatesNewUser(ctx context.Context, metadata *transactionpb.Metadata) (bool, error) { + typedMetadata := metadata.GetOpenAccounts() + if typedMetadata == nil { + return false, errors.New("unexpected metadata proto message") + } + + return typedMetadata.AccountSet == transactionpb.OpenAccountsMetadata_USER, nil } func (h *OpenAccountsIntentHandler) IsNoop(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) (bool, error) { - initiatiorOwnerAccount, err := common.NewAccountFromPublicKeyString(intentRecord.InitiatorOwnerAccount) - if err != nil { - return false, err + typedMetadata := metadata.GetOpenAccounts() + if typedMetadata == nil { + return false, errors.New("unexpected metadata proto message") } - _, err = h.data.GetLatestIntentByInitiatorAndType(ctx, intent.OpenAccounts, initiatiorOwnerAccount.PublicKey().ToBase58()) - if err == nil { - return true, nil - } else if err != intent.ErrIntentNotFound { + openAction := actions[0].GetOpenAccount() + if openAction == nil { + return false, NewActionValidationError(actions[0], "expected an open account action") + } + + var authorityToCheck *common.Account + var err error + switch typedMetadata.AccountSet { + case transactionpb.OpenAccountsMetadata_USER: + authorityToCheck, err = common.NewAccountFromPublicKeyString(intentRecord.InitiatorOwnerAccount) + if err != nil { + return false, err + } + case transactionpb.OpenAccountsMetadata_POOL: + authorityToCheck, err = common.NewAccountFromProto(actions[0].GetOpenAccount().Authority) + if err != nil { + return false, err + } + default: + return false, NewIntentValidationErrorf("unsupported account set: %s", typedMetadata.AccountSet) + } + + _, err = h.data.GetAccountInfoByAuthorityAddress(ctx, authorityToCheck.PublicKey().ToBase58()) + if err == account.ErrAccountInfoNotFound { + return false, nil + } else if err != nil { return false, err } + return true, nil +} - return false, nil +func (h *OpenAccountsIntentHandler) GetBalanceLocks(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*intentBalanceLock, error) { + return nil, nil } func (h *OpenAccountsIntentHandler) AllowCreation(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) error { @@ -128,36 +167,25 @@ func (h *OpenAccountsIntentHandler) AllowCreation(ctx context.Context, intentRec // if !h.conf.disableAntispamChecks.Get(ctx) { - allow, err := h.antispamGuard.AllowOpenAccounts(ctx, initiatiorOwnerAccount) + allow, err := h.antispamGuard.AllowOpenAccounts(ctx, initiatiorOwnerAccount, typedMetadata.AccountSet) if err != nil { return err } else if !allow { - return newIntentDeniedError("antispam guard denied account creation") + return NewIntentDeniedError("antispam guard denied account creation") } } // - // Part 2: Validate the owner hasn't already created an OpenAccounts intent + // Part 2: Validate the individual actions // - _, err = h.data.GetLatestIntentByInitiatorAndType(ctx, intent.OpenAccounts, initiatiorOwnerAccount.PublicKey().ToBase58()) - if err == nil { - return newStaleStateError("already submitted intent to open accounts") - } else if err != intent.ErrIntentNotFound { - return err - } - - // - // Part 3: Validate the individual actions - // - - err = h.validateActions(ctx, initiatiorOwnerAccount, actions) + err = h.validateActions(ctx, initiatiorOwnerAccount, typedMetadata, actions) if err != nil { return err } // - // Part 4: Local simulation + // Part 3: Local simulation // simResult, err := LocalSimulation(ctx, h.data, actions) @@ -166,45 +194,86 @@ func (h *OpenAccountsIntentHandler) AllowCreation(ctx context.Context, intentRec } // - // Part 5: Validate fee payments + // Part 4: Validate fee payments // return validateFeePayments(ctx, h.data, h.conf, intentRecord, simResult) } -func (h *OpenAccountsIntentHandler) validateActions(ctx context.Context, initiatiorOwnerAccount *common.Account, actions []*transactionpb.Action) error { - expectedActionCount := len(accountTypesToOpen) +func (h *OpenAccountsIntentHandler) validateActions( + ctx context.Context, + initiatiorOwnerAccount *common.Account, + typedMetadata *transactionpb.OpenAccountsMetadata, + actions []*transactionpb.Action, +) error { + type expectedAccountToOpen struct { + Type commonpb.AccountType + Index uint64 + } + + var expectedAccountsToOpen []expectedAccountToOpen + switch typedMetadata.AccountSet { + case transactionpb.OpenAccountsMetadata_USER: + expectedAccountsToOpen = []expectedAccountToOpen{ + { + Type: commonpb.AccountType_PRIMARY, + Index: 0, + }, + } + case transactionpb.OpenAccountsMetadata_POOL: + var nextPoolIndex uint64 + latestPoolAccountInfoRecord, err := h.data.GetLatestAccountInfoByOwnerAddressAndType(ctx, initiatiorOwnerAccount.PublicKey().ToBase58(), commonpb.AccountType_POOL) + switch err { + case nil: + nextPoolIndex = latestPoolAccountInfoRecord.Index + 1 + case account.ErrAccountInfoNotFound: + nextPoolIndex = 0 + default: + return err + } + + expectedAccountsToOpen = []expectedAccountToOpen{ + { + Type: commonpb.AccountType_POOL, + Index: nextPoolIndex, + }, + } + default: + return NewIntentValidationErrorf("unsupported account set: %s", typedMetadata.AccountSet) + } + + expectedActionCount := len(expectedAccountsToOpen) if len(actions) != expectedActionCount { - return newIntentValidationErrorf("expected %d total actions", expectedActionCount) + return NewIntentValidationErrorf("expected %d total actions", expectedActionCount) } - for i, expectedAccountType := range accountTypesToOpen { + for i, expectedAccountToOpen := range expectedAccountsToOpen { openAction := actions[i] if openAction.GetOpenAccount() == nil { - return newActionValidationError(openAction, "expected an open account action") + return NewActionValidationError(openAction, "expected an open account action") } - if openAction.GetOpenAccount().AccountType != expectedAccountType { - return newActionValidationErrorf(openAction, "account type must be %s", expectedAccountType) + if openAction.GetOpenAccount().AccountType != expectedAccountToOpen.Type { + return NewActionValidationErrorf(openAction, "account type must be %s", expectedAccountToOpen.Type) } if openAction.GetOpenAccount().Index != 0 { - return newActionValidationError(openAction, "index must be 0 for all newly opened accounts") + return NewActionValidationErrorf(openAction, "index must be %d", expectedAccountToOpen.Index) } if !bytes.Equal(openAction.GetOpenAccount().Owner.Value, initiatiorOwnerAccount.PublicKey().ToBytes()) { - return newActionValidationErrorf(openAction, "owner must be %s", initiatiorOwnerAccount.PublicKey().ToBase58()) + return NewActionValidationErrorf(openAction, "owner must be %s", initiatiorOwnerAccount.PublicKey().ToBase58()) } - switch expectedAccountType { + switch expectedAccountToOpen.Type { case commonpb.AccountType_PRIMARY: if !bytes.Equal(openAction.GetOpenAccount().Owner.Value, openAction.GetOpenAccount().Authority.Value) { - return newActionValidationErrorf(openAction, "authority must be %s", initiatiorOwnerAccount.PublicKey().ToBase58()) + return NewActionValidationErrorf(openAction, "authority must be %s", initiatiorOwnerAccount.PublicKey().ToBase58()) } default: if bytes.Equal(openAction.GetOpenAccount().Owner.Value, openAction.GetOpenAccount().Authority.Value) { - return newActionValidationErrorf(openAction, "authority cannot be %s", initiatiorOwnerAccount.PublicKey().ToBase58()) + return NewActionValidationErrorf(openAction, "authority cannot be %s", initiatiorOwnerAccount.PublicKey().ToBase58()) } } @@ -214,7 +283,7 @@ func (h *OpenAccountsIntentHandler) validateActions(ctx context.Context, initiat } if !bytes.Equal(openAction.GetOpenAccount().Token.Value, expectedVaultAccount.PublicKey().ToBytes()) { - return newActionValidationErrorf(openAction, "token must be %s", expectedVaultAccount.PublicKey().ToBase58()) + return NewActionValidationErrorf(openAction, "token must be %s", expectedVaultAccount.PublicKey().ToBase58()) } if err := validateTimelockUnlockStateDoesntExist(ctx, h.data, openAction.GetOpenAccount()); err != nil { @@ -238,6 +307,8 @@ type SendPublicPaymentIntentHandler struct { data code_data.Provider antispamGuard *antispam.Guard amlGuard *aml.Guard + + cachedDestinationAccountInfoRecord *account.Record } func NewSendPublicPaymentIntentHandler( @@ -276,6 +347,7 @@ func (h *SendPublicPaymentIntentHandler) PopulateMetadata(ctx context.Context, i if err != nil && err != account.ErrAccountInfoNotFound { return err } + h.cachedDestinationAccountInfoRecord = destinationAccountInfo intentRecord.IntentType = intent.SendPublicPayment intentRecord.SendPublicPaymentMetadata = &intent.SendPublicPaymentMetadata{ @@ -304,21 +376,69 @@ func (h *SendPublicPaymentIntentHandler) PopulateMetadata(ctx context.Context, i return nil } +func (h *SendPublicPaymentIntentHandler) CreatesNewUser(ctx context.Context, metadata *transactionpb.Metadata) (bool, error) { + return false, nil +} + func (h *SendPublicPaymentIntentHandler) IsNoop(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) (bool, error) { return false, nil } -func (h *SendPublicPaymentIntentHandler) GetAccountsWithBalancesToLock(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*common.Account, error) { +func (h *SendPublicPaymentIntentHandler) GetBalanceLocks(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*intentBalanceLock, error) { typedMetadata := metadata.GetSendPublicPayment() + if typedMetadata == nil { + return nil, errors.New("unexpected metadata proto message") + } sourceVault, err := common.NewAccountFromProto(typedMetadata.Source) if err != nil { return nil, err } - return []*common.Account{sourceVault}, nil + outgoingSourceBalanceLock, err := balance.GetOptimisticVersionLock(ctx, h.data, sourceVault) + if err != nil { + return nil, err + } + + intentBalanceLocks := []*intentBalanceLock{ + { + Account: sourceVault, + CommitFn: outgoingSourceBalanceLock.OnNewBalanceVersion, + }, + } + + if h.cachedDestinationAccountInfoRecord != nil { + switch h.cachedDestinationAccountInfoRecord.AccountType { + case commonpb.AccountType_POOL: + closeableDestinationVault, err := common.NewAccountFromProto(typedMetadata.Destination) + if err != nil { + return nil, err + } + + incomingDestinationBalanceLock, err := balance.GetOptimisticVersionLock(ctx, h.data, closeableDestinationVault) + if err != nil { + return nil, err + } + incomingDestinationOpenCloseLock := balance.NewOpenCloseStatusLock(closeableDestinationVault) + + intentBalanceLocks = append( + intentBalanceLocks, + &intentBalanceLock{ + Account: closeableDestinationVault, + CommitFn: incomingDestinationBalanceLock.RequireSameBalanceVerion, + }, + &intentBalanceLock{ + Account: closeableDestinationVault, + CommitFn: incomingDestinationOpenCloseLock.OnPaymentToAccount, + }, + ) + } + } + + return intentBalanceLocks, nil } +// todo: validation against Flipcash through generic interface for bet creation func (h *SendPublicPaymentIntentHandler) AllowCreation(ctx context.Context, intentRecord *intent.Record, untypedMetadata *transactionpb.Metadata, actions []*transactionpb.Action) error { typedMetadata := untypedMetadata.GetSendPublicPayment() if typedMetadata == nil { @@ -417,7 +537,6 @@ func (h *SendPublicPaymentIntentHandler) AllowCreation(ctx context.Context, inte return h.validateActions( ctx, initiatiorOwnerAccount, - initiatorAccountsByType, initiatorAccountsByVault, typedMetadata, actions, @@ -428,7 +547,6 @@ func (h *SendPublicPaymentIntentHandler) AllowCreation(ctx context.Context, inte func (h *SendPublicPaymentIntentHandler) validateActions( ctx context.Context, initiatorOwnerAccount *common.Account, - initiatorAccountsByType map[commonpb.AccountType][]*common.AccountRecords, initiatorAccountsByVault map[string]*common.AccountRecords, metadata *transactionpb.SendPublicPaymentMetadata, actions []*transactionpb.Action, @@ -449,17 +567,17 @@ func (h *SendPublicPaymentIntentHandler) validateActions( // if metadata.IsRemoteSend && metadata.IsWithdrawal { - return newIntentValidationError("remote send cannot be a withdraw") + return NewIntentValidationError("remote send cannot be a withdraw") } if !metadata.IsWithdrawal && !metadata.IsRemoteSend && len(actions) != 1 { - return newIntentValidationError("expected 1 action for payment") + return NewIntentValidationError("expected 1 action for payment") } if metadata.IsWithdrawal && len(actions) != 1 && len(actions) != 2 { - return newIntentValidationError("expected 1 or 2 actions for withdrawal") + return NewIntentValidationError("expected 1 or 2 actions for withdrawal") } if metadata.IsRemoteSend && len(actions) != 3 { - return newIntentValidationError("expected 3 actions for remote send") + return NewIntentValidationError("expected 3 actions for remote send") } // @@ -468,32 +586,37 @@ func (h *SendPublicPaymentIntentHandler) validateActions( sourceAccountRecords, ok := initiatorAccountsByVault[source.PublicKey().ToBase58()] if !ok || sourceAccountRecords.General.AccountType != commonpb.AccountType_PRIMARY { - return newIntentValidationError("source account must be a deposit account") + return NewIntentValidationError("source account must be a deposit account") } - destinationAccountInfo, err := h.data.GetAccountInfoByTokenAddress(ctx, destination.PublicKey().ToBase58()) - switch err { - case nil: + if h.cachedDestinationAccountInfoRecord != nil { // Remote sends must be to a brand new gift card account if metadata.IsRemoteSend { - return newIntentValidationError("destination must be a brand new gift card account") + return NewIntentValidationError("destination must be a brand new gift card account") + } + + // Code->Code public ayments can only be made to primary or pool accounts + switch h.cachedDestinationAccountInfoRecord.AccountType { + case commonpb.AccountType_PRIMARY, commonpb.AccountType_POOL: + default: + return NewIntentValidationError("destination account must be a PRIMARY or POOL account") } // Code->Code public withdraws must be done against other deposit accounts - if metadata.IsWithdrawal && destinationAccountInfo.AccountType != commonpb.AccountType_PRIMARY { - return newIntentValidationError("destination account must be a deposit account") + if metadata.IsWithdrawal && h.cachedDestinationAccountInfoRecord.AccountType != commonpb.AccountType_PRIMARY { + return NewIntentValidationError("destination account must be a PRIMARY account") } // Fee payments are not required for Code->Code public withdraws if metadata.IsWithdrawal && simResult.HasAnyFeePayments() { - return newIntentValidationErrorf("%s fee payment not required for code destination", transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL.String()) + return NewIntentValidationErrorf("%s fee payment not required for code destination", transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL.String()) } // And the destination cannot be the source of funds, since that results in a no-op - if source.PublicKey().ToBase58() == destinationAccountInfo.TokenAccount { - return newIntentValidationError("payment is a no-op") + if source.PublicKey().ToBase58() == h.cachedDestinationAccountInfoRecord.TokenAccount { + return NewIntentValidationError("payment is a no-op") } - case account.ErrAccountInfoNotFound: + } else { err = func() error { // Destination is to a brand new gift card that will be created as part of this // intent @@ -503,7 +626,7 @@ func (h *SendPublicPaymentIntentHandler) validateActions( // All payments to external destinations must be withdraws if !metadata.IsWithdrawal { - return newIntentValidationError("payments to external destinations must be withdrawals") + return NewIntentValidationError("payments to external destinations must be withdrawals") } // Ensure the destination is the core mint ATA for the client-provided owner, @@ -520,7 +643,7 @@ func (h *SendPublicPaymentIntentHandler) validateActions( } if ata.PublicKey().ToBase58() != destination.PublicKey().ToBase58() { - return newIntentValidationErrorf("destination is not the ata for %s", destinationOwner.PublicKey().ToBase58()) + return NewIntentValidationErrorf("destination is not the ata for %s", destinationOwner.PublicKey().ToBase58()) } } @@ -541,11 +664,11 @@ func (h *SendPublicPaymentIntentHandler) validateActions( } if !simResult.HasAnyFeePayments() { - return newIntentValidationErrorf("%s fee payment is required", transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL.String()) + return NewIntentValidationErrorf("%s fee payment is required", transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL.String()) } if metadata.DestinationOwner == nil { - return newIntentValidationError("destination owner account is required to derive ata") + return NewIntentValidationError("destination owner account is required to derive ata") } } @@ -554,8 +677,6 @@ func (h *SendPublicPaymentIntentHandler) validateActions( if err != nil { return err } - default: - return err } // @@ -573,7 +694,7 @@ func (h *SendPublicPaymentIntentHandler) validateActions( // most checks that isn't specific to an intent. feePayments := simResult.GetFeePayments() if len(feePayments) > 1 { - return newIntentValidationError("expected at most 1 fee payment") + return NewIntentValidationError("expected at most 1 fee payment") } for _, feePayment := range feePayments { expectedDestinationPayment += feePayment.DeltaQuarks @@ -581,11 +702,11 @@ func (h *SendPublicPaymentIntentHandler) validateActions( destinationSimulation, ok := simResult.SimulationsByAccount[destination.PublicKey().ToBase58()] if !ok { - return newIntentValidationErrorf("must send payment to destination account %s", destination.PublicKey().ToBase58()) + return NewIntentValidationErrorf("must send payment to destination account %s", destination.PublicKey().ToBase58()) } else if destinationSimulation.Transfers[0].IsPrivate || destinationSimulation.Transfers[0].IsWithdraw { - return newActionValidationError(destinationSimulation.Transfers[0].Action, "payment sent to destination must be a public transfer") + return NewActionValidationError(destinationSimulation.Transfers[0].Action, "payment sent to destination must be a public transfer") } else if destinationSimulation.GetDeltaQuarks(false) != expectedDestinationPayment { - return newActionValidationErrorf(destinationSimulation.Transfers[0].Action, "must send %d quarks to destination account", expectedDestinationPayment) + return NewActionValidationErrorf(destinationSimulation.Transfers[0].Action, "must send %d quarks to destination account", expectedDestinationPayment) } // @@ -595,9 +716,9 @@ func (h *SendPublicPaymentIntentHandler) validateActions( sourceSimulation, ok := simResult.SimulationsByAccount[source.PublicKey().ToBase58()] if !ok { - return newIntentValidationErrorf("must send payment from source account %s", source.PublicKey().ToBase58()) + return NewIntentValidationErrorf("must send payment from source account %s", source.PublicKey().ToBase58()) } else if sourceSimulation.GetDeltaQuarks(false) != -int64(metadata.ExchangeData.Quarks) { - return newActionValidationErrorf(sourceSimulation.Transfers[0].Action, "must send %d quarks from source account", metadata.ExchangeData.Quarks) + return NewActionValidationErrorf(sourceSimulation.Transfers[0].Action, "must send %d quarks from source account", metadata.ExchangeData.Quarks) } // Part 4: Generic validation of actions that move money @@ -611,7 +732,7 @@ func (h *SendPublicPaymentIntentHandler) validateActions( if metadata.IsRemoteSend { if len(simResult.GetOpenedAccounts()) != 1 { - return newIntentValidationError("expected 1 account opened") + return NewIntentValidationError("expected 1 account opened") } err = validateGiftCardAccountOpened( @@ -627,32 +748,32 @@ func (h *SendPublicPaymentIntentHandler) validateActions( closedAccounts := simResult.GetClosedAccounts() if len(FilterAutoReturnedAccounts(closedAccounts)) != 0 { - return newIntentValidationError("expected no closed accounts outside of auto-returns") + return NewIntentValidationError("expected no closed accounts outside of auto-returns") } if len(closedAccounts) != 1 { - return newIntentValidationError("expected exactly 1 auto-returned account") + return NewIntentValidationError("expected exactly 1 auto-returned account") } autoReturns := destinationSimulation.GetAutoReturns() if len(autoReturns) != 1 { - return newIntentValidationError("expected auto-return for the remote send gift card") + return NewIntentValidationError("expected auto-return for the remote send gift card") } else if autoReturns[0].IsPrivate || !autoReturns[0].IsWithdraw { - return newActionValidationError(destinationSimulation.Transfers[0].Action, "auto-return must be a public withdraw") + return NewActionValidationError(destinationSimulation.Transfers[0].Action, "auto-return must be a public withdraw") } else if autoReturns[0].DeltaQuarks != -int64(metadata.ExchangeData.Quarks) { - return newActionValidationErrorf(autoReturns[0].Action, "must auto-return %d quarks from remote send gift card", metadata.ExchangeData.Quarks) + return NewActionValidationErrorf(autoReturns[0].Action, "must auto-return %d quarks from remote send gift card", metadata.ExchangeData.Quarks) } autoReturns = sourceSimulation.GetAutoReturns() if len(autoReturns) != 1 { - return newIntentValidationError("gift card auto-return balance must go to the source account") + return NewIntentValidationError("gift card auto-return balance must go to the source account") } } else { if len(simResult.GetOpenedAccounts()) > 0 { - return newIntentValidationError("cannot open any account") + return NewIntentValidationError("cannot open any account") } if len(simResult.GetClosedAccounts()) > 0 { - return newIntentValidationError("cannot close any account") + return NewIntentValidationError("cannot close any account") } } @@ -667,7 +788,6 @@ func (h *SendPublicPaymentIntentHandler) OnCommittedToDB(ctx context.Context, in return nil } -// Generally needs a rewrite to send funds to the primary account type ReceivePaymentsPubliclyIntentHandler struct { conf *conf data code_data.Provider @@ -712,7 +832,7 @@ func (h *ReceivePaymentsPubliclyIntentHandler) PopulateMetadata(ctx context.Cont // fetch this metadata up front so we don't need to do it every time in history. giftCardIssuedIntentRecord, err := h.data.GetOriginalGiftCardIssuedIntent(ctx, giftCardVault.PublicKey().ToBase58()) if err == intent.ErrIntentNotFound { - return newIntentValidationError("source is not a remote send gift card") + return NewIntentValidationError("source is not a remote send gift card") } else if err != nil { return err } @@ -737,16 +857,31 @@ func (h *ReceivePaymentsPubliclyIntentHandler) PopulateMetadata(ctx context.Cont return nil } +func (h *ReceivePaymentsPubliclyIntentHandler) CreatesNewUser(ctx context.Context, metadata *transactionpb.Metadata) (bool, error) { + return false, nil +} + func (h *ReceivePaymentsPubliclyIntentHandler) IsNoop(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) (bool, error) { return false, nil } -func (h *ReceivePaymentsPubliclyIntentHandler) GetAccountsWithBalancesToLock(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*common.Account, error) { +func (h *ReceivePaymentsPubliclyIntentHandler) GetBalanceLocks(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*intentBalanceLock, error) { giftCardVault, err := common.NewAccountFromPublicKeyString(intentRecord.ReceivePaymentsPubliclyMetadata.Source) if err != nil { return nil, err } - return []*common.Account{giftCardVault}, nil + + outgoingGiftCardBalanceLock, err := balance.GetOptimisticVersionLock(ctx, h.data, giftCardVault) + if err != nil { + return nil, err + } + + return []*intentBalanceLock{ + { + Account: giftCardVault, + CommitFn: outgoingGiftCardBalanceLock.OnNewBalanceVersion, + }, + }, nil } func (h *ReceivePaymentsPubliclyIntentHandler) AllowCreation(ctx context.Context, intentRecord *intent.Record, untypedMetadata *transactionpb.Metadata, actions []*transactionpb.Action) error { @@ -756,11 +891,11 @@ func (h *ReceivePaymentsPubliclyIntentHandler) AllowCreation(ctx context.Context } if !typedMetadata.IsRemoteSend { - return newIntentValidationError("only remote send is supported") + return NewIntentValidationError("only remote send is supported") } if typedMetadata.ExchangeData != nil { - return newIntentValidationError("exchange data cannot be set") + return NewIntentValidationError("exchange data cannot be set") } initiatiorOwnerAccount, err := common.NewAccountFromPublicKeyString(intentRecord.InitiatorOwnerAccount) @@ -869,7 +1004,7 @@ func (h *ReceivePaymentsPubliclyIntentHandler) validateActions( simResult *LocalSimulationResult, ) error { if len(actions) != 1 { - return newIntentValidationError("expected 1 action") + return NewIntentValidationError("expected 1 action") } // @@ -886,7 +1021,7 @@ func (h *ReceivePaymentsPubliclyIntentHandler) validateActions( destinationAccountInfo := initiatorAccountsByType[commonpb.AccountType_PRIMARY][0].General destinationSimulation, ok := simResult.SimulationsByAccount[destinationAccountInfo.TokenAccount] if !ok { - return newActionValidationError(actions[0], "must send payment to primary account") + return NewActionValidationError(actions[0], "must send payment to primary account") } // @@ -899,11 +1034,11 @@ func (h *ReceivePaymentsPubliclyIntentHandler) validateActions( sourceSimulation, ok := simResult.SimulationsByAccount[source.PublicKey().ToBase58()] if !ok { - return newIntentValidationError("must receive payments from source account") + return NewIntentValidationError("must receive payments from source account") } else if sourceSimulation.GetDeltaQuarks(false) != -int64(metadata.Quarks) { - return newActionValidationErrorf(sourceSimulation.Transfers[0].Action, "must receive %d quarks from source account", metadata.Quarks) + return NewActionValidationErrorf(sourceSimulation.Transfers[0].Action, "must receive %d quarks from source account", metadata.Quarks) } else if sourceSimulation.Transfers[0].IsPrivate || !sourceSimulation.Transfers[0].IsWithdraw { - return newActionValidationError(sourceSimulation.Transfers[0].Action, "transfer must be a public withdraw") + return NewActionValidationError(sourceSimulation.Transfers[0].Action, "transfer must be a public withdraw") } // @@ -911,9 +1046,9 @@ func (h *ReceivePaymentsPubliclyIntentHandler) validateActions( // if destinationSimulation.GetDeltaQuarks(false) != int64(metadata.Quarks) { - return newActionValidationErrorf(actions[0], "must receive %d quarks to temp incoming account", metadata.Quarks) + return NewActionValidationErrorf(actions[0], "must receive %d quarks to temp incoming account", metadata.Quarks) } else if destinationSimulation.Transfers[0].IsPrivate || !destinationSimulation.Transfers[0].IsWithdraw { - return newActionValidationError(sourceSimulation.Transfers[0].Action, "transfer must be a public withdraw") + return NewActionValidationError(sourceSimulation.Transfers[0].Action, "transfer must be a public withdraw") } // @@ -921,14 +1056,14 @@ func (h *ReceivePaymentsPubliclyIntentHandler) validateActions( // if len(simResult.GetOpenedAccounts()) > 0 { - return newIntentValidationError("cannot open any account") + return NewIntentValidationError("cannot open any account") } closedAccounts := simResult.GetClosedAccounts() if len(closedAccounts) != 1 { - return newIntentValidationError("must close 1 account") + return NewIntentValidationError("must close 1 account") } else if closedAccounts[0].TokenAccount.PublicKey().ToBase58() != source.PublicKey().ToBase58() { - return newActionValidationError(actions[0], "must close source account") + return NewActionValidationError(actions[0], "must close source account") } // @@ -946,6 +1081,302 @@ func (h *ReceivePaymentsPubliclyIntentHandler) OnCommittedToDB(ctx context.Conte return nil } +type PublicDistributionIntentHandler struct { + conf *conf + data code_data.Provider + antispamGuard *antispam.Guard + amlGuard *aml.Guard +} + +func NewPublicDistributionIntentHandler( + conf *conf, + data code_data.Provider, + antispamGuard *antispam.Guard, + amlGuard *aml.Guard, +) CreateIntentHandler { + return &PublicDistributionIntentHandler{ + conf: conf, + data: data, + antispamGuard: antispamGuard, + amlGuard: amlGuard, + } +} + +func (h *PublicDistributionIntentHandler) PopulateMetadata(ctx context.Context, intentRecord *intent.Record, protoMetadata *transactionpb.Metadata) error { + typedProtoMetadata := protoMetadata.GetPublicDistribution() + if typedProtoMetadata == nil { + return errors.New("unexpected metadata proto message") + } + + source, err := common.NewAccountFromPublicKeyBytes(typedProtoMetadata.Source.Value) + if err != nil { + return err + } + + var totalQuarks uint64 + for _, distribution := range typedProtoMetadata.Distributions { + totalQuarks += distribution.Quarks + } + + usdExchangeRecord, err := h.data.GetExchangeRate(ctx, currency_lib.USD, currency_util.GetLatestExchangeRateTime()) + if err != nil { + return errors.Wrap(err, "error getting current usd exchange rate") + } + + intentRecord.IntentType = intent.PublicDistribution + intentRecord.PublicDistributionMetadata = &intent.PublicDistributionMetadata{ + Source: source.PublicKey().ToBase58(), + Quantity: totalQuarks, + UsdMarketValue: usdExchangeRecord.Rate * float64(totalQuarks) / float64(common.CoreMintQuarksPerUnit), + } + + return nil +} + +func (h *PublicDistributionIntentHandler) CreatesNewUser(ctx context.Context, metadata *transactionpb.Metadata) (bool, error) { + return false, nil +} + +func (h *PublicDistributionIntentHandler) IsNoop(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) (bool, error) { + return false, nil +} + +func (h *PublicDistributionIntentHandler) GetBalanceLocks(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*intentBalanceLock, error) { + poolVault, err := common.NewAccountFromPublicKeyString(intentRecord.ReceivePaymentsPubliclyMetadata.Source) + if err != nil { + return nil, err + } + + outgoingPoolBalanceLock, err := balance.GetOptimisticVersionLock(ctx, h.data, poolVault) + if err != nil { + return nil, err + } + + incomingPoolBalanceLock := balance.NewOpenCloseStatusLock(poolVault) + + return []*intentBalanceLock{ + { + Account: poolVault, + CommitFn: outgoingPoolBalanceLock.OnNewBalanceVersion, + }, + { + Account: poolVault, + CommitFn: incomingPoolBalanceLock.OnClose, + }, + }, nil +} + +// todo: validation against Flipcash through generic interface for pool resolution +func (h *PublicDistributionIntentHandler) AllowCreation(ctx context.Context, intentRecord *intent.Record, untypedMetadata *transactionpb.Metadata, actions []*transactionpb.Action) error { + typedMetadata := untypedMetadata.GetPublicDistribution() + if typedMetadata == nil { + return errors.New("unexpected metadata proto message") + } + + initiatiorOwnerAccount, err := common.NewAccountFromPublicKeyString(intentRecord.InitiatorOwnerAccount) + if err != nil { + return err + } + + // + // Part 1: Antispam guard checks against the owner + // + + if !h.conf.disableAntispamChecks.Get(ctx) { + allow, err := h.antispamGuard.AllowDistribution(ctx, initiatiorOwnerAccount, true) + if err != nil { + return err + } else if !allow { + return ErrTooManyPayments + } + } + + // + // Part 2: AML checks against the owner + // + + if !h.conf.disableAmlChecks.Get(ctx) { + allow, err := h.amlGuard.AllowMoneyMovement(ctx, intentRecord) + if err != nil { + return err + } else if !allow { + return ErrTransactionLimitExceeded + } + } + + // + // Part 3: Pool account validation + // + + poolVaultAccount, err := common.NewAccountFromProto(typedMetadata.Source) + if err != nil { + return err + } + var totalQuarksDistributed uint64 + for _, distribution := range typedMetadata.Distributions { + totalQuarksDistributed += distribution.Quarks + } + err = validateDistributedPool(ctx, h.data, poolVaultAccount, totalQuarksDistributed) + if err != nil { + return err + } + + // + // Part 4: Local simulation + // + + simResult, err := LocalSimulation(ctx, h.data, actions) + if err != nil { + return err + } + + // + // Part 4: Validate fee payments + // + + err = validateFeePayments(ctx, h.data, h.conf, intentRecord, simResult) + if err != nil { + return err + } + + // + // Part 5: Validate actions + // + + return h.validateActions(ctx, typedMetadata, actions, simResult) +} + +func (h *PublicDistributionIntentHandler) validateActions( + ctx context.Context, + metadata *transactionpb.PublicDistributionMetadata, + actions []*transactionpb.Action, + simResult *LocalSimulationResult, +) error { + if len(actions) != len(metadata.Distributions) { + return NewIntentValidationErrorf("expected 1 action per distribution") + } + + // + // Part 1: Validate source and destination accounts are valid + // + + // Note: Already validated to be a pool account elsewhere + source, err := common.NewAccountFromProto(metadata.Source) + if err != nil { + return err + } + + var destinations []*common.Account + var totalQuarksDistributed uint64 + destinationSet := map[string]any{} + for _, distribution := range metadata.Distributions { + destination, err := common.NewAccountFromProto(distribution.Destination) + if err != nil { + return err + } + + _, ok := destinationSet[destination.PublicKey().ToBase58()] + if ok { + return NewIntentValidationErrorf("duplicate destination account detected: %s", destination.PublicKey().ToBase58()) + } + destinationSet[destination.PublicKey().ToBase58()] = true + + destinationAccountInfoRecord, err := h.data.GetAccountInfoByTokenAddress(ctx, destination.PublicKey().ToBase58()) + switch err { + case nil: + if destinationAccountInfoRecord.AccountType != commonpb.AccountType_PRIMARY { + return NewIntentValidationErrorf("destination account %s must be a primary account", destination.PublicKey().ToBase58()) + } + case account.ErrAccountInfoNotFound: + return NewIntentValidationErrorf("destination account %s is not a code account", destination.PublicKey().ToBase58()) + default: + return err + } + + totalQuarksDistributed += distribution.Quarks + destinations = append(destinations, destination) + } + if len(destinations) == 0 { + return NewIntentValidationError("must distribute to at least one destination") + } + + // + // Part 2: Validate actions match intent + // + + // + // Part 2.1: Check source account pays exact quark amount to each destination + // + + sourceSimulation, ok := simResult.SimulationsByAccount[source.PublicKey().ToBase58()] + if !ok { + return NewIntentValidationError("must send distributions from source account") + } else if len(sourceSimulation.Transfers) != len(metadata.Distributions) { + return NewIntentValidationErrorf("must send %d distributions from source account", len(metadata.Distributions)) + } else if sourceSimulation.GetDeltaQuarks(false) != -int64(totalQuarksDistributed) { + return NewIntentValidationErrorf("must send %d quarks from source account", totalQuarksDistributed) + } + for i, transfer := range sourceSimulation.Transfers { + expectWithdrawal := i == len(destinations)-1 + if transfer.IsPrivate { + return NewActionValidationError(transfer.Action, "distribution sent from source must be public") + } else if expectWithdrawal && !transfer.IsWithdraw { + return NewActionValidationError(transfer.Action, "distribution sent from source must be a withdrawal") + } else if !expectWithdrawal && transfer.IsWithdraw { + return NewActionValidationError(transfer.Action, "distribution sent from source must be a transfer") + } + } + + // + // Part 2.2: Check each destination account is paid exact dstirbution quark amount from source account + // + + for i, destination := range destinations { + expectWithdrawal := i == len(destinations)-1 + destinationSimulation, ok := simResult.SimulationsByAccount[destination.PublicKey().ToBase58()] + if !ok { + return NewIntentValidationErrorf("must send distribution to destination account %s", destination.PublicKey().ToBase58()) + } else if len(destinationSimulation.Transfers) != 1 { + return NewIntentValidationErrorf("must send distriubtion to destination account %s in one action", destination.PublicKey().ToBase58()) + } else if destinationSimulation.Transfers[0].IsPrivate { + return NewActionValidationError(destinationSimulation.Transfers[0].Action, "distribution sent to destination must be public") + } else if expectWithdrawal && !destinationSimulation.Transfers[0].IsWithdraw { + return NewActionValidationError(destinationSimulation.Transfers[0].Action, "distribution sent to destination must be a withdrawal") + } else if !expectWithdrawal && destinationSimulation.Transfers[0].IsWithdraw { + return NewActionValidationError(destinationSimulation.Transfers[0].Action, "distribution sent to destination must be a transfer") + } else if destinationSimulation.GetDeltaQuarks(false) != int64(metadata.Distributions[i].Quarks) { + return NewActionValidationErrorf(destinationSimulation.Transfers[0].Action, "must send %d quarks to destination account", int64(metadata.Distributions[i].Quarks)) + } + } + + // Part 3: Validate open and closed accounts + + if len(simResult.GetOpenedAccounts()) > 0 { + return NewIntentValidationError("cannot open any account") + } + + closedAccounts := simResult.GetClosedAccounts() + if len(closedAccounts) != 1 { + return NewIntentValidationError("must close 1 account") + } else if closedAccounts[0].TokenAccount.PublicKey().ToBase58() != source.PublicKey().ToBase58() { + return NewIntentValidationError("must close source account") + } else if closedAccounts[0].CloseAction.GetNoPrivacyWithdraw() == nil { + return NewActionValidationError(closedAccounts[0].CloseAction, "must close source account with a withdraw") + } else if closedAccounts[0].IsAutoReturned { + return NewActionValidationError(closedAccounts[0].CloseAction, "action cannot be an auto-return") + } + + return nil +} + +func (h *PublicDistributionIntentHandler) OnSaveToDB(ctx context.Context, intentRecord *intent.Record) error { + return nil +} + +func (h *PublicDistributionIntentHandler) OnCommittedToDB(ctx context.Context, intentRecord *intent.Record) error { + return nil +} + func validateAllUserAccountsManagedByCode(ctx context.Context, initiatorAccounts []*common.AccountRecords) error { // Try to unlock *ANY* latest account, and you're done for _, accountRecords := range initiatorAccounts { @@ -986,7 +1417,7 @@ func validateMoneyMovementActionUserAccounts( sourceAccountInfo, ok := initiatorAccountsByVault[source.PublicKey().ToBase58()] if !ok || sourceAccountInfo.General.AccountType != commonpb.AccountType_PRIMARY { - return newActionValidationError(action, "source account must be a deposit account") + return NewActionValidationError(action, "source account must be a deposit account") } case *transactionpb.Action_NoPrivacyWithdraw: // No privacy withdraws are used in two ways depending on the intent: @@ -1012,7 +1443,7 @@ func validateMoneyMovementActionUserAccounts( case intent.SendPublicPayment, intent.ReceivePaymentsPublicly: destinationAccountInfo, ok := initiatorAccountsByVault[destination.PublicKey().ToBase58()] if !ok || destinationAccountInfo.General.AccountType != commonpb.AccountType_PRIMARY { - return newActionValidationError(action, "source account must be the primary account") + return NewActionValidationError(action, "source account must be the primary account") } } case *transactionpb.Action_FeePayment: @@ -1030,7 +1461,7 @@ func validateMoneyMovementActionUserAccounts( sourceAccountInfo, ok := initiatorAccountsByVault[source.PublicKey().ToBase58()] if !ok || sourceAccountInfo.General.AccountType != commonpb.AccountType_PRIMARY { - return newActionValidationError(action, "source account must be the primary account") + return NewActionValidationError(action, "source account must be the primary account") } default: continue @@ -1040,7 +1471,7 @@ func validateMoneyMovementActionUserAccounts( if err != nil { return err } else if !bytes.Equal(expectedTimelockVault.PublicKey().ToBytes(), source.PublicKey().ToBytes()) { - return newActionValidationErrorf(action, "authority is invalid") + return NewActionValidationErrorf(action, "authority is invalid") } } @@ -1061,7 +1492,7 @@ func validateGiftCardAccountOpened( case *transactionpb.Action_OpenAccount: if typed.OpenAccount.AccountType == commonpb.AccountType_REMOTE_SEND_GIFT_CARD { if openAction != nil { - return newIntentValidationErrorf("multiple open actions for %s account type", commonpb.AccountType_REMOTE_SEND_GIFT_CARD) + return NewIntentValidationErrorf("multiple open actions for %s account type", commonpb.AccountType_REMOTE_SEND_GIFT_CARD) } openAction = action @@ -1070,19 +1501,19 @@ func validateGiftCardAccountOpened( } if openAction == nil { - return newIntentValidationErrorf("open account action for %s account type missing", commonpb.AccountType_REMOTE_SEND_GIFT_CARD) + return NewIntentValidationErrorf("open account action for %s account type missing", commonpb.AccountType_REMOTE_SEND_GIFT_CARD) } if bytes.Equal(openAction.GetOpenAccount().Owner.Value, initiatorOwnerAccount.PublicKey().ToBytes()) { - return newActionValidationErrorf(openAction, "owner cannot be %s", initiatorOwnerAccount.PublicKey().ToBase58()) + return NewActionValidationErrorf(openAction, "owner cannot be %s", initiatorOwnerAccount.PublicKey().ToBase58()) } if !bytes.Equal(openAction.GetOpenAccount().Owner.Value, openAction.GetOpenAccount().Authority.Value) { - return newActionValidationErrorf(openAction, "authority must be %s", openAction.GetOpenAccount().Owner.Value) + return NewActionValidationErrorf(openAction, "authority must be %s", openAction.GetOpenAccount().Owner.Value) } if openAction.GetOpenAccount().Index != 0 { - return newActionValidationError(openAction, "index must be 0") + return NewActionValidationError(openAction, "index must be 0") } derivedVaultAccount, err := getExpectedTimelockVaultFromProtoAccount(openAction.GetOpenAccount().Authority) @@ -1091,11 +1522,11 @@ func validateGiftCardAccountOpened( } if !bytes.Equal(expectedGiftCardVault.PublicKey().ToBytes(), derivedVaultAccount.PublicKey().ToBytes()) { - return newActionValidationErrorf(openAction, "token must be %s", expectedGiftCardVault.PublicKey().ToBase58()) + return NewActionValidationErrorf(openAction, "token must be %s", expectedGiftCardVault.PublicKey().ToBase58()) } if !bytes.Equal(openAction.GetOpenAccount().Token.Value, derivedVaultAccount.PublicKey().ToBytes()) { - return newActionValidationErrorf(openAction, "token must be %s", derivedVaultAccount.PublicKey().ToBase58()) + return NewActionValidationErrorf(openAction, "token must be %s", derivedVaultAccount.PublicKey().ToBase58()) } if err := validateTimelockUnlockStateDoesntExist(ctx, data, openAction.GetOpenAccount()); err != nil { @@ -1110,7 +1541,7 @@ func validateExternalTokenAccountWithinIntent(ctx context.Context, data code_dat if err != nil { return err } else if !isValid { - return newIntentValidationError(message) + return NewIntentValidationError(message) } return nil } @@ -1121,9 +1552,9 @@ func validateExchangeDataWithinIntent(ctx context.Context, data code_data.Provid return err } else if !isValid { if strings.Contains(message, "stale") { - return newStaleStateError(message) + return NewStaleStateError(message) } - return newIntentValidationError(message) + return NewIntentValidationError(message) } return nil } @@ -1146,14 +1577,14 @@ func validateFeePayments( } if simResult.HasAnyFeePayments() && expectedFeeType == transactionpb.FeePaymentAction_UNKNOWN { - return newIntentValidationError("intent doesn't require a fee payment") + return NewIntentValidationError("intent doesn't require a fee payment") } if expectedFeeType == transactionpb.FeePaymentAction_UNKNOWN { return nil } if !simResult.HasAnyFeePayments() && !isFeeOptional { - return newIntentValidationErrorf("expected a %s fee payment", expectedFeeType.String()) + return NewIntentValidationErrorf("expected a %s fee payment", expectedFeeType.String()) } if !simResult.HasAnyFeePayments() && isFeeOptional { return nil @@ -1161,14 +1592,14 @@ func validateFeePayments( feePayments := simResult.GetFeePayments() if len(feePayments) > 1 { - return newIntentValidationError("expected at most 1 fee payment") + return NewIntentValidationError("expected at most 1 fee payment") } else if len(feePayments) == 0 { return nil } feePayment := feePayments[0] if feePayment.Action.GetFeePayment().Type != expectedFeeType { - return newActionValidationErrorf(feePayment.Action, "expected a %s fee payment", expectedFeeType.String()) + return NewActionValidationErrorf(feePayment.Action, "expected a %s fee payment", expectedFeeType.String()) } var expectedUsdValue float64 @@ -1181,7 +1612,7 @@ func validateFeePayments( feeAmount := feePayment.DeltaQuarks if feeAmount >= 0 { - return newActionValidationError(feePayment.Action, "fee payment amount is negative") + return NewActionValidationError(feePayment.Action, "fee payment amount is negative") } feeAmount = -feeAmount // Because it's coming out of a user account in this simulation @@ -1201,7 +1632,7 @@ func validateFeePayments( } if !foundUsdExchangeRecord { - return newActionValidationErrorf(feePayment.Action, "%s fee payment amount must be $%.2f USD", expectedFeeType.String(), expectedUsdValue) + return NewActionValidationErrorf(feePayment.Action, "%s fee payment amount must be $%.2f USD", expectedFeeType.String(), expectedUsdValue) } return nil @@ -1214,7 +1645,7 @@ func validateClaimedGiftCard(ctx context.Context, data code_data.Provider, giftC accountInfoRecord, err := data.GetAccountInfoByTokenAddress(ctx, giftCardVaultAccount.PublicKey().ToBase58()) if err == account.ErrAccountInfoNotFound || accountInfoRecord.AccountType != commonpb.AccountType_REMOTE_SEND_GIFT_CARD { - return newIntentValidationError("source is not a remote send gift card") + return NewIntentValidationError("source is not a remote send gift card") } // @@ -1223,7 +1654,7 @@ func validateClaimedGiftCard(ctx context.Context, data code_data.Provider, giftC _, err = data.GetGiftCardClaimedAction(ctx, giftCardVaultAccount.PublicKey().ToBase58()) if err == nil { - return newStaleStateError("gift card balance has already been claimed") + return NewStaleStateError("gift card balance has already been claimed") } else if err == action.ErrActionNotFound { // No action to claim it, so we can proceed } else if err != nil { @@ -1240,7 +1671,7 @@ func validateClaimedGiftCard(ctx context.Context, data code_data.Provider, giftC } if err == nil && autoReturnActionRecord.State != action.StateUnknown { - return newStaleStateError("gift card is expired") + return NewStaleStateError("gift card is expired") } // @@ -1258,7 +1689,7 @@ func validateClaimedGiftCard(ctx context.Context, data code_data.Provider, giftC // Better error messaging, since we know we'll never reopen the account // and the balance is guaranteed to be claimed (not necessarily through // Code server though). - return newStaleStateError("gift card balance has already been claimed") + return NewStaleStateError("gift card balance has already been claimed") } return ErrNotManagedByCode } @@ -1274,9 +1705,9 @@ func validateClaimedGiftCard(ctx context.Context, data code_data.Provider, giftC return err } else if giftCardBalance == 0 { // Shouldn't be hit with checks from part 3, but left for completeness - return newStaleStateError("gift card balance has already been claimed") + return NewStaleStateError("gift card balance has already been claimed") } else if giftCardBalance != claimedAmount { - return newIntentValidationErrorf("must receive entire gift card balance of %d quarks", giftCardBalance) + return NewIntentValidationErrorf("must receive entire gift card balance of %d quarks", giftCardBalance) } // @@ -1284,7 +1715,50 @@ func validateClaimedGiftCard(ctx context.Context, data code_data.Provider, giftC // if time.Since(accountInfoRecord.CreatedAt) >= async_account.GiftCardExpiry-time.Minute { - return newStaleStateError("gift card is expired") + return NewStaleStateError("gift card is expired") + } + + return nil +} + +func validateDistributedPool(ctx context.Context, data code_data.Provider, poolVaultAccount *common.Account, distributedAmount uint64) error { + // + // Part 1: Is the account a pool? + // + + accountInfoRecord, err := data.GetAccountInfoByTokenAddress(ctx, poolVaultAccount.PublicKey().ToBase58()) + if err == account.ErrAccountInfoNotFound || accountInfoRecord.AccountType != commonpb.AccountType_POOL { + return NewIntentValidationError("source is not a pool account") + } + + // + // Part 2: Is the full amount being distributed? + // + + poolBalance, err := balance.CalculateFromCache(ctx, data, poolVaultAccount) + if err != nil { + return err + } else if poolBalance == 0 { + return NewStaleStateError("pool balance has already been distributed") + } else if distributedAmount != poolBalance { + return NewIntentValidationErrorf("must distribute entire pool balance of %d quarks", poolBalance) + } + + // + // Part 3: Is the pool account managed by Code? + // + + timelockRecord, err := data.GetTimelockByVault(ctx, poolVaultAccount.PublicKey().ToBase58()) + if err != nil { + return err + } + + isManagedByCode := common.IsManagedByCode(ctx, timelockRecord) + if !isManagedByCode { + if timelockRecord.IsClosed() { + return NewStaleStateError("pool balance has already been distributed") + } + return ErrNotManagedByCode } return nil @@ -1304,7 +1778,7 @@ func validateTimelockUnlockStateDoesntExist(ctx context.Context, data code_data. _, err = data.GetBlockchainAccountInfo(ctx, timelockAccounts.Unlock.PublicKey().ToBase58(), solana.CommitmentFinalized) switch err { case nil: - return newIntentDeniedError("an account being opened has already initiated an unlock") + return NewIntentDeniedError("an account being opened has already initiated an unlock") case solana.ErrNoAccountInfo: return nil default: diff --git a/pkg/code/server/transaction/local_simulation.go b/pkg/code/server/transaction/local_simulation.go index 89699eb2..c50913bb 100644 --- a/pkg/code/server/transaction/local_simulation.go +++ b/pkg/code/server/transaction/local_simulation.go @@ -71,9 +71,9 @@ func LocalSimulation(ctx context.Context, data code_data.Provider, actions []*tr // // todo: We don't support reopening accounts yet if timelockRecord.IsClosed() { - return nil, newActionWithStaleStateError(action, "account is already closed and won't be reused") + return nil, NewActionWithStaleStateError(action, "account is already closed and won't be reused") } - return nil, newActionWithStaleStateError(action, "account is already opened") + return nil, NewActionWithStaleStateError(action, "account is already opened") } else if err != nil && err != timelock.ErrTimelockNotFound { return nil, err } @@ -174,7 +174,7 @@ func LocalSimulation(ctx context.Context, data code_data.Provider, actions []*tr amount := typedAction.NoPrivacyWithdraw.Amount if source.PublicKey().ToBase58() == destination.PublicKey().ToBase58() { - return nil, newActionValidationError(action, "source and destination accounts must be different") + return nil, NewActionValidationError(action, "source and destination accounts must be different") } simulations = append( @@ -215,7 +215,7 @@ func LocalSimulation(ctx context.Context, data code_data.Provider, actions []*tr return nil, err } if timelockAccounts.Vault.PublicKey().ToBase58() != derivedTimelockVault.PublicKey().ToBase58() { - return nil, newActionValidationErrorf(action, "token must be %s", timelockAccounts.Vault.PublicKey().ToBase58()) + return nil, NewActionValidationErrorf(action, "token must be %s", timelockAccounts.Vault.PublicKey().ToBase58()) } // Combine the simulated action to all previously simulated actions with @@ -224,7 +224,7 @@ func LocalSimulation(ctx context.Context, data code_data.Provider, actions []*tr for _, txn := range simulation.Transfers { // Attempt to transfer 0 quarks if txn.DeltaQuarks == 0 { - return nil, newActionValidationError(action, "transaction with 0 quarks") + return nil, NewActionValidationError(action, "transaction with 0 quarks") } } @@ -232,27 +232,27 @@ func LocalSimulation(ctx context.Context, data code_data.Provider, actions []*tr if ok { // Attempt to open an already closed account, which isn't supported if combined.Closed && simulation.Opened { - return nil, newActionValidationError(action, "account cannot be reopened") + return nil, NewActionValidationError(action, "account cannot be reopened") } // Attempt to open an already opened account if combined.Opened && simulation.Opened { - return nil, newActionValidationError(action, "account is already opened in another action") + return nil, NewActionValidationError(action, "account is already opened in another action") } // Funds transferred to an account before it was opened if len(combined.Transfers) > 0 && simulation.Opened { - return nil, newActionValidationError(action, "opened an account after transferring funds to it") + return nil, NewActionValidationError(action, "opened an account after transferring funds to it") } // Attempt to close an already closed account if combined.Closed && simulation.Closed { - return nil, newActionValidationError(action, "account is already closed in another action") + return nil, NewActionValidationError(action, "account is already closed in another action") } // Attempt to send/receive funds to a closed account if combined.Closed && len(simulation.Transfers) > 0 { - return nil, newActionValidationError(action, "account is closed and cannot send/receive funds") + return nil, NewActionValidationError(action, "account is closed and cannot send/receive funds") } combined.Transfers = append(combined.Transfers, simulation.Transfers...) @@ -327,7 +327,7 @@ func (s TokenAccountSimulation) EnforceBalances(ctx context.Context, data code_d for _, transfer := range s.Transfers { newBalance = newBalance + transfer.DeltaQuarks if newBalance < 0 { - return newActionValidationError(transfer.Action, "insufficient balance to perform action") + return NewActionValidationError(transfer.Action, "insufficient balance to perform action") } // If it's withdrawn out of this account, remove any remaining balance. @@ -339,7 +339,7 @@ func (s TokenAccountSimulation) EnforceBalances(ctx context.Context, data code_d } if s.Closed && newBalance != 0 { - return newActionValidationError(s.CloseAction, "attempt to close an account with a non-zero balance") + return NewActionValidationError(s.CloseAction, "attempt to close an account with a non-zero balance") } return nil diff --git a/pkg/code/server/transaction/server.go b/pkg/code/server/transaction/server.go index 06708b73..a1094610 100644 --- a/pkg/code/server/transaction/server.go +++ b/pkg/code/server/transaction/server.go @@ -25,7 +25,8 @@ type transactionServer struct { auth *auth_util.RPCSignatureVerifier - airdropIntegration AirdropIntegration + submitIntentIntegration SubmitIntentIntegration + airdropIntegration AirdropIntegration antispamGuard *antispam.Guard amlGuard *aml.Guard @@ -45,6 +46,7 @@ type transactionServer struct { func NewTransactionServer( data code_data.Provider, + submitIntentIntegration SubmitIntentIntegration, airdropIntegration AirdropIntegration, antispamGuard *antispam.Guard, amlGuard *aml.Guard, @@ -69,7 +71,8 @@ func NewTransactionServer( auth: auth_util.NewRPCSignatureVerifier(data), - airdropIntegration: airdropIntegration, + submitIntentIntegration: submitIntentIntegration, + airdropIntegration: airdropIntegration, antispamGuard: antispamGuard, amlGuard: amlGuard,