diff --git a/ocp/data/currency/memory/store.go b/ocp/data/currency/memory/store.go index 4f07247..5b099ce 100644 --- a/ocp/data/currency/memory/store.go +++ b/ocp/data/currency/memory/store.go @@ -214,7 +214,7 @@ func (s *store) SaveMetadata(ctx context.Context, data *currency.MetadataRecord) } for _, item := range s.metadataRecords { - if strings.EqualFold(item.Name, data.Name) { + if strings.EqualFold(item.Name, data.Name) && item.State != currency.MetadataStateAbandoned { return currency.ErrDuplicateCurrency } } @@ -336,7 +336,7 @@ func (s *store) IsNameAvailable(_ context.Context, name string) (bool, error) { defer s.mu.Unlock() for _, item := range s.metadataRecords { - if strings.EqualFold(item.Name, name) { + if strings.EqualFold(item.Name, name) && item.State != currency.MetadataStateAbandoned { return false, nil } } diff --git a/ocp/data/currency/model.go b/ocp/data/currency/model.go index 2b2ad89..ec7ccf6 100644 --- a/ocp/data/currency/model.go +++ b/ocp/data/currency/model.go @@ -17,6 +17,8 @@ const ( MetadataStateExecutingInitialPurchase MetadataStateCompletingInitialization MetadataStateFinalValidation + MetadataStateAbandoning + MetadataStateAbandoned ) type SocialLinkType uint8 @@ -350,6 +352,10 @@ func (s MetadataState) String() string { return "completing_initialization" case MetadataStateFinalValidation: return "final_validation" + case MetadataStateAbandoning: + return "abandoning" + case MetadataStateAbandoned: + return "abandoned" } return "unknown" } diff --git a/ocp/data/currency/postgres/model.go b/ocp/data/currency/postgres/model.go index da84037..952e422 100644 --- a/ocp/data/currency/postgres/model.go +++ b/ocp/data/currency/postgres/model.go @@ -552,8 +552,9 @@ func dbCountMetadataByState(ctx context.Context, db *sqlx.DB, state currency.Met func dbIsNameAvailable(ctx context.Context, db *sqlx.DB, name string) (bool, error) { var count uint64 err := db.GetContext(ctx, &count, - `SELECT COUNT(*) FROM `+metadataTableName+` WHERE LOWER(name) = LOWER($1)`, + `SELECT COUNT(*) FROM `+metadataTableName+` WHERE LOWER(name) = LOWER($1) AND state != $2`, name, + currency.MetadataStateAbandoned, ) if err != nil { return false, err diff --git a/ocp/data/currency/postgres/store_test.go b/ocp/data/currency/postgres/store_test.go index bfeb45e..f97c27c 100644 --- a/ocp/data/currency/postgres/store_test.go +++ b/ocp/data/currency/postgres/store_test.go @@ -70,7 +70,7 @@ const ( created_by TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL ); - CREATE UNIQUE INDEX ocp__core_currencymetadata__name__idx ON ocp__core_currencymetadata (LOWER(name)); + CREATE UNIQUE INDEX ocp__core_currencymetadata__name__idx ON ocp__core_currencymetadata (LOWER(name)) WHERE state != 8; CREATE TABLE ocp__core_currencyreserve ( id serial NOT NULL PRIMARY KEY, diff --git a/ocp/data/currency/tests/tests.go b/ocp/data/currency/tests/tests.go index 5d3e656..ccfe9f8 100644 --- a/ocp/data/currency/tests/tests.go +++ b/ocp/data/currency/tests/tests.go @@ -21,6 +21,7 @@ func RunTests(t *testing.T, s currency.Store, teardown func()) { testMetadataSaveWithVersioning, testMetadataUniqueNameConstraint, testIsNameAvailable, + testAbandonedCurrencyNameReuse, testGetAllMetadataByState, testGetAllMints, testCountMints, @@ -349,6 +350,114 @@ func testIsNameAvailable(t *testing.T, s currency.Store) { assert.True(t, available) } +func testAbandonedCurrencyNameReuse(t *testing.T, s currency.Store) { + ctx := context.Background() + + record := ¤cy.MetadataRecord{ + Name: "AbandonedCurrency", + Symbol: "AB1", + Description: "A currency that will be abandoned", + ImageUrl: "https://example.com/ab1.png", + BillColors: []string{"#000000"}, + SocialLinks: []currency.SocialLink{{Type: currency.SocialLinkTypeWebsite, Value: "https://example.com"}}, + + Seed: "abandonedseed1", + Authority: "abandonedauth1", + + Mint: "abandonedmint111111111111111111111111111111111", + MintBump: 255, + Decimals: currencycreator.DefaultMintDecimals, + + CurrencyConfig: "abandonedconfig1111111111111111111111111111", + CurrencyConfigBump: 255, + + LiquidityPool: "abandonedpool11111111111111111111111111111111", + LiquidityPoolBump: 255, + + VaultMint: "abandonedvmint1111111111111111111111111111111", + VaultMintBump: 255, + + VaultCore: "abandonedvcore1111111111111111111111111111111", + VaultCoreBump: 255, + + SellFeeBps: currencycreator.DefaultSellFeeBps, + + Alt: "abandonedalt1111111111111111111111111111111111", + + CreatedBy: "abandonedcreator1", + CreatedAt: time.Now(), + } + + require.NoError(t, s.SaveMetadata(ctx, record)) + + // Name should not be available while active + available, err := s.IsNameAvailable(ctx, "AbandonedCurrency") + require.NoError(t, err) + assert.False(t, available) + + // Case-insensitive should also not be available + available, err = s.IsNameAvailable(ctx, "abandonedcurrency") + require.NoError(t, err) + assert.False(t, available) + + // Transition to abandoned state + record.State = currency.MetadataStateAbandoned + require.NoError(t, s.SaveMetadata(ctx, record)) + + // Name should now be available + available, err = s.IsNameAvailable(ctx, "AbandonedCurrency") + require.NoError(t, err) + assert.True(t, available) + + // Case-insensitive should also be available + available, err = s.IsNameAvailable(ctx, "abandonedcurrency") + require.NoError(t, err) + assert.True(t, available) + + // Should be able to create a new currency with the same name + record2 := ¤cy.MetadataRecord{ + Name: "AbandonedCurrency", + Symbol: "AB2", + Description: "Reusing the abandoned name", + ImageUrl: "https://example.com/ab2.png", + BillColors: []string{"#FFFFFF"}, + SocialLinks: []currency.SocialLink{{Type: currency.SocialLinkTypeWebsite, Value: "https://example2.com"}}, + + Seed: "abandonedseed2", + Authority: "abandonedauth2", + + Mint: "abandonedmint222222222222222222222222222222222", + MintBump: 255, + Decimals: currencycreator.DefaultMintDecimals, + + CurrencyConfig: "abandonedconfig2222222222222222222222222222", + CurrencyConfigBump: 255, + + LiquidityPool: "abandonedpool22222222222222222222222222222222", + LiquidityPoolBump: 255, + + VaultMint: "abandonedvmint2222222222222222222222222222222", + VaultMintBump: 255, + + VaultCore: "abandonedvcore2222222222222222222222222222222", + VaultCoreBump: 255, + + SellFeeBps: currencycreator.DefaultSellFeeBps, + + Alt: "abandonedalt2222222222222222222222222222222222", + + CreatedBy: "abandonedcreator2", + CreatedAt: time.Now(), + } + + require.NoError(t, s.SaveMetadata(ctx, record2)) + + // New currency's name should no longer be available + available, err = s.IsNameAvailable(ctx, "AbandonedCurrency") + require.NoError(t, err) + assert.False(t, available) +} + func testGetAllMetadataByState(t *testing.T, s currency.Store) { t.Run("testGetAllMetadataByState", func(t *testing.T) { ctx := context.Background() diff --git a/ocp/worker/currency/launcher/config.go b/ocp/worker/currency/launcher/config.go index 8585585..c22bebe 100644 --- a/ocp/worker/currency/launcher/config.go +++ b/ocp/worker/currency/launcher/config.go @@ -1,6 +1,8 @@ package launcher import ( + "time" + "github.com/code-payments/ocp-server/config" "github.com/code-payments/ocp-server/config/env" ) @@ -13,11 +15,15 @@ const ( BatchSizeConfigEnvName = envConfigPrefix + "WORKER_BATCH_SIZE" defaultBatchSize = 100 + + InitialPurchaseTimeoutConfigEnvName = envConfigPrefix + "INITIAL_PURCHASE_TIMEOUT" + defaultInitialPurchaseTimeout = 10 * time.Minute ) type conf struct { - subsidizer config.String - batchSize config.Uint64 + subsidizer config.String + batchSize config.Uint64 + initialPurchaseTimeout config.Duration } // ConfigProvider defines how config values are pulled @@ -27,8 +33,9 @@ type ConfigProvider func() *conf func WithEnvConfigs() ConfigProvider { return func() *conf { return &conf{ - subsidizer: env.NewStringConfig(SubsidizerConfigEnvName, defaultSubsidizer), - batchSize: env.NewUint64Config(BatchSizeConfigEnvName, defaultBatchSize), + subsidizer: env.NewStringConfig(SubsidizerConfigEnvName, defaultSubsidizer), + batchSize: env.NewUint64Config(BatchSizeConfigEnvName, defaultBatchSize), + initialPurchaseTimeout: env.NewDurationConfig(InitialPurchaseTimeoutConfigEnvName, defaultInitialPurchaseTimeout), } } } diff --git a/ocp/worker/currency/launcher/metrics.go b/ocp/worker/currency/launcher/metrics.go index cd7d62d..a1c3db2 100644 --- a/ocp/worker/currency/launcher/metrics.go +++ b/ocp/worker/currency/launcher/metrics.go @@ -30,6 +30,7 @@ func (p *runtime) metricsGaugeWorker(ctx context.Context) error { currency.MetadataStateCompletingInitialization, currency.MetadataStateFinalValidation, currency.MetadataStateAvailable, + currency.MetadataStateAbandoning, } { count, err := p.data.GetCurrencyMetadataCountByState(ctx, state) if err != nil { diff --git a/ocp/worker/currency/launcher/runtime.go b/ocp/worker/currency/launcher/runtime.go index 1c4a546..100994d 100644 --- a/ocp/worker/currency/launcher/runtime.go +++ b/ocp/worker/currency/launcher/runtime.go @@ -37,9 +37,11 @@ func New(log *zap.Logger, data ocp_data.Provider, configProvider ConfigProvider) func (p *runtime) Start(ctx context.Context, interval time.Duration) error { for _, state := range []currency.MetadataState{ + currency.MetadataStateWaitingForInitialPurchase, currency.MetadataStateFundingAuthority, currency.MetadataStateCompletingInitialization, currency.MetadataStateFinalValidation, + currency.MetadataStateAbandoning, } { go func(state currency.MetadataState) { err := p.worker(ctx, state, interval) diff --git a/ocp/worker/currency/launcher/util.go b/ocp/worker/currency/launcher/util.go index 3cf7b44..6489f01 100644 --- a/ocp/worker/currency/launcher/util.go +++ b/ocp/worker/currency/launcher/util.go @@ -103,6 +103,26 @@ func (p *runtime) markCurrencyMetadataAvailable(ctx context.Context, record *cur return p.data.SaveCurrencyMetadata(ctx, record) } +func (p *runtime) markCurrencyMetadataAbandoning(ctx context.Context, record *currency.MetadataRecord) error { + err := p.validateCurrencyMetadataState(record, currency.MetadataStateWaitingForInitialPurchase) + if err != nil { + return err + } + + record.State = currency.MetadataStateAbandoning + return p.data.SaveCurrencyMetadata(ctx, record) +} + +func (p *runtime) markCurrencyMetadataAbandoned(ctx context.Context, record *currency.MetadataRecord) error { + err := p.validateCurrencyMetadataState(record, currency.MetadataStateAbandoning) + if err != nil { + return err + } + + record.State = currency.MetadataStateAbandoned + return p.data.SaveCurrencyMetadata(ctx, record) +} + func (p *runtime) putInitialReserveState(ctx context.Context, record *currency.MetadataRecord) error { // Note: The live reserve state is initialized by the swap worker on initial purchase @@ -199,6 +219,36 @@ func validateMinimumAuthorityFunding(ctx context.Context, data ocp_data.Provider } } +func returnAuthorityFundsToSubsidizer(ctx context.Context, data ocp_data.Provider, subsidizer, authority *common.Account) error { + ai, _, err := data.GetBlockchainAccountInfo(ctx, authority.PublicKey().ToBase58(), solana.CommitmentFinalized) + if err == solana.ErrNoAccountInfo { + return nil + } else if err != nil { + return errors.Wrap(err, "error getting authority account info") + } + + if ai.Lamports == 0 { + return nil + } + + bh, err := data.GetBlockchainLatestBlockhash(ctx) + if err != nil { + return errors.Wrap(err, "error getting latest blockhash") + } + + txn, err := transaction_util.MakeSolanaTransferTransaction(subsidizer, authority, subsidizer, ai.Lamports, bh) + if err != nil { + return errors.Wrap(err, "error making solana transfer transaction") + } + + err = txn.Sign(subsidizer.PrivateKey().ToBytes(), authority.PrivateKey().ToBytes()) + if err != nil { + return errors.Wrap(err, "error signing transaction") + } + + return transaction_util.SubmitAndWaitForFinalization(ctx, data, &txn) +} + func fundAuthority(ctx context.Context, data ocp_data.Provider, subsidizer, account *common.Account, amount uint64) error { bh, err := data.GetBlockchainLatestBlockhash(ctx) if err != nil { diff --git a/ocp/worker/currency/launcher/worker.go b/ocp/worker/currency/launcher/worker.go index 37238a8..c407450 100644 --- a/ocp/worker/currency/launcher/worker.go +++ b/ocp/worker/currency/launcher/worker.go @@ -94,12 +94,16 @@ func (p *runtime) handle(ctx context.Context, record *currency.MetadataRecord) e var err error switch record.State { + case currency.MetadataStateWaitingForInitialPurchase: + err = p.handleStateWaitingForInitialPurchase(ctx, record) case currency.MetadataStateFundingAuthority: err = p.handleStateFundingAuthority(ctx, record) case currency.MetadataStateCompletingInitialization: err = p.handleStateCompletingInitialization(ctx, record) case currency.MetadataStateFinalValidation: err = p.handleStateFinalValidation(ctx, record) + case currency.MetadataStateAbandoning: + err = p.handleStateAbandoning(ctx, record) } if err != nil { log.With(zap.Error(err)).Warn("failure processing currency to launch") @@ -108,6 +112,20 @@ func (p *runtime) handle(ctx context.Context, record *currency.MetadataRecord) e return nil } +func (p *runtime) handleStateWaitingForInitialPurchase(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord) error { + err := p.validateCurrencyMetadataState(currencyMetadataRecord, currency.MetadataStateWaitingForInitialPurchase) + if err != nil { + return err + } + + timeout := p.conf.initialPurchaseTimeout.Get(ctx) + if time.Since(currencyMetadataRecord.CreatedAt) < timeout { + return nil + } + + return p.markCurrencyMetadataAbandoning(ctx, currencyMetadataRecord) +} + // Note: Assumes unique authority per currency func (p *runtime) handleStateFundingAuthority(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord) error { err := p.validateCurrencyMetadataState(currencyMetadataRecord, currency.MetadataStateFundingAuthority) @@ -375,3 +393,27 @@ func (p *runtime) handleStateFinalValidation(ctx context.Context, currencyMetada return p.initializeCreatorAcccount(ctx, currencyMetadataRecord, accounts) }) } + +func (p *runtime) handleStateAbandoning(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord) error { + err := p.validateCurrencyMetadataState(currencyMetadataRecord, currency.MetadataStateAbandoning) + if err != nil { + return err + } + + authorityVaultRecord, err := p.data.GetKey(ctx, currencyMetadataRecord.Authority) + if err != nil { + return errors.Wrap(err, "error getting authority vault record") + } + + authority, err := common.NewAccountFromPrivateKeyString(authorityVaultRecord.PrivateKey) + if err != nil { + return errors.Wrap(err, "invalid authority private key") + } + + err = returnAuthorityFundsToSubsidizer(ctx, p.data, p.subsidizer, authority) + if err != nil { + return errors.Wrap(err, "error returning authority funds to subsidizer") + } + + return p.markCurrencyMetadataAbandoned(ctx, currencyMetadataRecord) +} diff --git a/ocp/worker/swap/util.go b/ocp/worker/swap/util.go index 0fac148..db4f676 100644 --- a/ocp/worker/swap/util.go +++ b/ocp/worker/swap/util.go @@ -106,42 +106,96 @@ func (p *runtime) markSwapFinalized(ctx context.Context, swapRecord *swap.Record }) } -func (p *runtime) markSwapFailed(ctx context.Context, record *swap.Record) error { +func (p *runtime) markSwapFailed(ctx context.Context, swapRecord *swap.Record) error { + toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) + if err != nil { + return err + } + + var destinationCurrencyMetadataRecord *currency.MetadataRecord + if !common.IsCoreMint(toMint) { + destinationCurrencyMetadataRecord, err = p.data.GetCurrencyMetadata(ctx, swapRecord.ToMint) + if err != nil { + return err + } + err = p.validateCurrencyMetadataState(destinationCurrencyMetadataRecord, currency.MetadataStateExecutingInitialPurchase, currency.MetadataStateAvailable) + if err != nil { + return err + } + } + return p.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error { - err := p.validateSwapState(record, swap.StateSubmitting) + err := p.validateSwapState(swapRecord, swap.StateSubmitting) if err != nil { return err } - err = p.markNonceReleasedDueToSubmittedTransaction(ctx, record) + err = p.markNonceReleasedDueToSubmittedTransaction(ctx, swapRecord) if err != nil { return err } - record.TransactionBlob = nil - record.State = swap.StateFailed - return p.data.SaveSwap(ctx, record) + if !common.IsCoreMint(toMint) { + if destinationCurrencyMetadataRecord.State == currency.MetadataStateExecutingInitialPurchase { + destinationCurrencyMetadataRecord.State = currency.MetadataStateAbandoning + err = p.data.SaveCurrencyMetadata(ctx, destinationCurrencyMetadataRecord) + if err != nil { + return err + } + } + } + + swapRecord.TransactionBlob = nil + swapRecord.State = swap.StateFailed + return p.data.SaveSwap(ctx, swapRecord) }) } -func (p *runtime) markSwapCancelled(ctx context.Context, record *swap.Record) error { +func (p *runtime) markSwapCancelled(ctx context.Context, swapRecord *swap.Record) error { + toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) + if err != nil { + return err + } + + var destinationCurrencyMetadataRecord *currency.MetadataRecord + if !common.IsCoreMint(toMint) { + destinationCurrencyMetadataRecord, err = p.data.GetCurrencyMetadata(ctx, swapRecord.ToMint) + if err != nil { + return err + } + err = p.validateCurrencyMetadataState(destinationCurrencyMetadataRecord, currency.MetadataStateWaitingForInitialPurchase, currency.MetadataStateAvailable) + if err != nil { + return err + } + } + return p.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error { - err := p.validateSwapState(record, swap.StateCreated, swap.StateFunding, swap.StateFunded) + err := p.validateSwapState(swapRecord, swap.StateCreated, swap.StateFunding, swap.StateFunded) if err != nil { return err } - switch record.State { + switch swapRecord.State { case swap.StateCreated, swap.StateFunding, swap.StateFunded: - err = p.markNonceAvailableDueToCancelledSwap(ctx, record) + err = p.markNonceAvailableDueToCancelledSwap(ctx, swapRecord) if err != nil { return err } } - record.TransactionBlob = nil - record.State = swap.StateCancelled - return p.data.SaveSwap(ctx, record) + if !common.IsCoreMint(toMint) { + if destinationCurrencyMetadataRecord.State == currency.MetadataStateWaitingForInitialPurchase { + destinationCurrencyMetadataRecord.State = currency.MetadataStateAbandoning + err = p.data.SaveCurrencyMetadata(ctx, destinationCurrencyMetadataRecord) + if err != nil { + return err + } + } + } + + swapRecord.TransactionBlob = nil + swapRecord.State = swap.StateCancelled + return p.data.SaveSwap(ctx, swapRecord) }) }