Skip to content

Commit 6ba0b27

Browse files
authored
Implement abandoned currencies (#169)
* Support currency abandon states * Implement state machines for currency abandoning and abandoned states
1 parent f484187 commit 6ba0b27

11 files changed

Lines changed: 293 additions & 21 deletions

File tree

ocp/data/currency/memory/store.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func (s *store) SaveMetadata(ctx context.Context, data *currency.MetadataRecord)
214214
}
215215

216216
for _, item := range s.metadataRecords {
217-
if strings.EqualFold(item.Name, data.Name) {
217+
if strings.EqualFold(item.Name, data.Name) && item.State != currency.MetadataStateAbandoned {
218218
return currency.ErrDuplicateCurrency
219219
}
220220
}
@@ -336,7 +336,7 @@ func (s *store) IsNameAvailable(_ context.Context, name string) (bool, error) {
336336
defer s.mu.Unlock()
337337

338338
for _, item := range s.metadataRecords {
339-
if strings.EqualFold(item.Name, name) {
339+
if strings.EqualFold(item.Name, name) && item.State != currency.MetadataStateAbandoned {
340340
return false, nil
341341
}
342342
}

ocp/data/currency/model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const (
1717
MetadataStateExecutingInitialPurchase
1818
MetadataStateCompletingInitialization
1919
MetadataStateFinalValidation
20+
MetadataStateAbandoning
21+
MetadataStateAbandoned
2022
)
2123

2224
type SocialLinkType uint8
@@ -350,6 +352,10 @@ func (s MetadataState) String() string {
350352
return "completing_initialization"
351353
case MetadataStateFinalValidation:
352354
return "final_validation"
355+
case MetadataStateAbandoning:
356+
return "abandoning"
357+
case MetadataStateAbandoned:
358+
return "abandoned"
353359
}
354360
return "unknown"
355361
}

ocp/data/currency/postgres/model.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,8 +552,9 @@ func dbCountMetadataByState(ctx context.Context, db *sqlx.DB, state currency.Met
552552
func dbIsNameAvailable(ctx context.Context, db *sqlx.DB, name string) (bool, error) {
553553
var count uint64
554554
err := db.GetContext(ctx, &count,
555-
`SELECT COUNT(*) FROM `+metadataTableName+` WHERE LOWER(name) = LOWER($1)`,
555+
`SELECT COUNT(*) FROM `+metadataTableName+` WHERE LOWER(name) = LOWER($1) AND state != $2`,
556556
name,
557+
currency.MetadataStateAbandoned,
557558
)
558559
if err != nil {
559560
return false, err

ocp/data/currency/postgres/store_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ const (
7070
created_by TEXT NOT NULL,
7171
created_at TIMESTAMP WITH TIME ZONE NOT NULL
7272
);
73-
CREATE UNIQUE INDEX ocp__core_currencymetadata__name__idx ON ocp__core_currencymetadata (LOWER(name));
73+
CREATE UNIQUE INDEX ocp__core_currencymetadata__name__idx ON ocp__core_currencymetadata (LOWER(name)) WHERE state != 8;
7474
CREATE TABLE ocp__core_currencyreserve (
7575
id serial NOT NULL PRIMARY KEY,
7676

ocp/data/currency/tests/tests.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func RunTests(t *testing.T, s currency.Store, teardown func()) {
2121
testMetadataSaveWithVersioning,
2222
testMetadataUniqueNameConstraint,
2323
testIsNameAvailable,
24+
testAbandonedCurrencyNameReuse,
2425
testGetAllMetadataByState,
2526
testGetAllMints,
2627
testCountMints,
@@ -349,6 +350,114 @@ func testIsNameAvailable(t *testing.T, s currency.Store) {
349350
assert.True(t, available)
350351
}
351352

353+
func testAbandonedCurrencyNameReuse(t *testing.T, s currency.Store) {
354+
ctx := context.Background()
355+
356+
record := &currency.MetadataRecord{
357+
Name: "AbandonedCurrency",
358+
Symbol: "AB1",
359+
Description: "A currency that will be abandoned",
360+
ImageUrl: "https://example.com/ab1.png",
361+
BillColors: []string{"#000000"},
362+
SocialLinks: []currency.SocialLink{{Type: currency.SocialLinkTypeWebsite, Value: "https://example.com"}},
363+
364+
Seed: "abandonedseed1",
365+
Authority: "abandonedauth1",
366+
367+
Mint: "abandonedmint111111111111111111111111111111111",
368+
MintBump: 255,
369+
Decimals: currencycreator.DefaultMintDecimals,
370+
371+
CurrencyConfig: "abandonedconfig1111111111111111111111111111",
372+
CurrencyConfigBump: 255,
373+
374+
LiquidityPool: "abandonedpool11111111111111111111111111111111",
375+
LiquidityPoolBump: 255,
376+
377+
VaultMint: "abandonedvmint1111111111111111111111111111111",
378+
VaultMintBump: 255,
379+
380+
VaultCore: "abandonedvcore1111111111111111111111111111111",
381+
VaultCoreBump: 255,
382+
383+
SellFeeBps: currencycreator.DefaultSellFeeBps,
384+
385+
Alt: "abandonedalt1111111111111111111111111111111111",
386+
387+
CreatedBy: "abandonedcreator1",
388+
CreatedAt: time.Now(),
389+
}
390+
391+
require.NoError(t, s.SaveMetadata(ctx, record))
392+
393+
// Name should not be available while active
394+
available, err := s.IsNameAvailable(ctx, "AbandonedCurrency")
395+
require.NoError(t, err)
396+
assert.False(t, available)
397+
398+
// Case-insensitive should also not be available
399+
available, err = s.IsNameAvailable(ctx, "abandonedcurrency")
400+
require.NoError(t, err)
401+
assert.False(t, available)
402+
403+
// Transition to abandoned state
404+
record.State = currency.MetadataStateAbandoned
405+
require.NoError(t, s.SaveMetadata(ctx, record))
406+
407+
// Name should now be available
408+
available, err = s.IsNameAvailable(ctx, "AbandonedCurrency")
409+
require.NoError(t, err)
410+
assert.True(t, available)
411+
412+
// Case-insensitive should also be available
413+
available, err = s.IsNameAvailable(ctx, "abandonedcurrency")
414+
require.NoError(t, err)
415+
assert.True(t, available)
416+
417+
// Should be able to create a new currency with the same name
418+
record2 := &currency.MetadataRecord{
419+
Name: "AbandonedCurrency",
420+
Symbol: "AB2",
421+
Description: "Reusing the abandoned name",
422+
ImageUrl: "https://example.com/ab2.png",
423+
BillColors: []string{"#FFFFFF"},
424+
SocialLinks: []currency.SocialLink{{Type: currency.SocialLinkTypeWebsite, Value: "https://example2.com"}},
425+
426+
Seed: "abandonedseed2",
427+
Authority: "abandonedauth2",
428+
429+
Mint: "abandonedmint222222222222222222222222222222222",
430+
MintBump: 255,
431+
Decimals: currencycreator.DefaultMintDecimals,
432+
433+
CurrencyConfig: "abandonedconfig2222222222222222222222222222",
434+
CurrencyConfigBump: 255,
435+
436+
LiquidityPool: "abandonedpool22222222222222222222222222222222",
437+
LiquidityPoolBump: 255,
438+
439+
VaultMint: "abandonedvmint2222222222222222222222222222222",
440+
VaultMintBump: 255,
441+
442+
VaultCore: "abandonedvcore2222222222222222222222222222222",
443+
VaultCoreBump: 255,
444+
445+
SellFeeBps: currencycreator.DefaultSellFeeBps,
446+
447+
Alt: "abandonedalt2222222222222222222222222222222222",
448+
449+
CreatedBy: "abandonedcreator2",
450+
CreatedAt: time.Now(),
451+
}
452+
453+
require.NoError(t, s.SaveMetadata(ctx, record2))
454+
455+
// New currency's name should no longer be available
456+
available, err = s.IsNameAvailable(ctx, "AbandonedCurrency")
457+
require.NoError(t, err)
458+
assert.False(t, available)
459+
}
460+
352461
func testGetAllMetadataByState(t *testing.T, s currency.Store) {
353462
t.Run("testGetAllMetadataByState", func(t *testing.T) {
354463
ctx := context.Background()

ocp/worker/currency/launcher/config.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package launcher
22

33
import (
4+
"time"
5+
46
"github.com/code-payments/ocp-server/config"
57
"github.com/code-payments/ocp-server/config/env"
68
)
@@ -13,11 +15,15 @@ const (
1315

1416
BatchSizeConfigEnvName = envConfigPrefix + "WORKER_BATCH_SIZE"
1517
defaultBatchSize = 100
18+
19+
InitialPurchaseTimeoutConfigEnvName = envConfigPrefix + "INITIAL_PURCHASE_TIMEOUT"
20+
defaultInitialPurchaseTimeout = 10 * time.Minute
1621
)
1722

1823
type conf struct {
19-
subsidizer config.String
20-
batchSize config.Uint64
24+
subsidizer config.String
25+
batchSize config.Uint64
26+
initialPurchaseTimeout config.Duration
2127
}
2228

2329
// ConfigProvider defines how config values are pulled
@@ -27,8 +33,9 @@ type ConfigProvider func() *conf
2733
func WithEnvConfigs() ConfigProvider {
2834
return func() *conf {
2935
return &conf{
30-
subsidizer: env.NewStringConfig(SubsidizerConfigEnvName, defaultSubsidizer),
31-
batchSize: env.NewUint64Config(BatchSizeConfigEnvName, defaultBatchSize),
36+
subsidizer: env.NewStringConfig(SubsidizerConfigEnvName, defaultSubsidizer),
37+
batchSize: env.NewUint64Config(BatchSizeConfigEnvName, defaultBatchSize),
38+
initialPurchaseTimeout: env.NewDurationConfig(InitialPurchaseTimeoutConfigEnvName, defaultInitialPurchaseTimeout),
3239
}
3340
}
3441
}

ocp/worker/currency/launcher/metrics.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func (p *runtime) metricsGaugeWorker(ctx context.Context) error {
3030
currency.MetadataStateCompletingInitialization,
3131
currency.MetadataStateFinalValidation,
3232
currency.MetadataStateAvailable,
33+
currency.MetadataStateAbandoning,
3334
} {
3435
count, err := p.data.GetCurrencyMetadataCountByState(ctx, state)
3536
if err != nil {

ocp/worker/currency/launcher/runtime.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ func New(log *zap.Logger, data ocp_data.Provider, configProvider ConfigProvider)
3737

3838
func (p *runtime) Start(ctx context.Context, interval time.Duration) error {
3939
for _, state := range []currency.MetadataState{
40+
currency.MetadataStateWaitingForInitialPurchase,
4041
currency.MetadataStateFundingAuthority,
4142
currency.MetadataStateCompletingInitialization,
4243
currency.MetadataStateFinalValidation,
44+
currency.MetadataStateAbandoning,
4345
} {
4446
go func(state currency.MetadataState) {
4547
err := p.worker(ctx, state, interval)

ocp/worker/currency/launcher/util.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,26 @@ func (p *runtime) markCurrencyMetadataAvailable(ctx context.Context, record *cur
103103
return p.data.SaveCurrencyMetadata(ctx, record)
104104
}
105105

106+
func (p *runtime) markCurrencyMetadataAbandoning(ctx context.Context, record *currency.MetadataRecord) error {
107+
err := p.validateCurrencyMetadataState(record, currency.MetadataStateWaitingForInitialPurchase)
108+
if err != nil {
109+
return err
110+
}
111+
112+
record.State = currency.MetadataStateAbandoning
113+
return p.data.SaveCurrencyMetadata(ctx, record)
114+
}
115+
116+
func (p *runtime) markCurrencyMetadataAbandoned(ctx context.Context, record *currency.MetadataRecord) error {
117+
err := p.validateCurrencyMetadataState(record, currency.MetadataStateAbandoning)
118+
if err != nil {
119+
return err
120+
}
121+
122+
record.State = currency.MetadataStateAbandoned
123+
return p.data.SaveCurrencyMetadata(ctx, record)
124+
}
125+
106126
func (p *runtime) putInitialReserveState(ctx context.Context, record *currency.MetadataRecord) error {
107127
// Note: The live reserve state is initialized by the swap worker on initial purchase
108128

@@ -199,6 +219,36 @@ func validateMinimumAuthorityFunding(ctx context.Context, data ocp_data.Provider
199219
}
200220
}
201221

222+
func returnAuthorityFundsToSubsidizer(ctx context.Context, data ocp_data.Provider, subsidizer, authority *common.Account) error {
223+
ai, _, err := data.GetBlockchainAccountInfo(ctx, authority.PublicKey().ToBase58(), solana.CommitmentFinalized)
224+
if err == solana.ErrNoAccountInfo {
225+
return nil
226+
} else if err != nil {
227+
return errors.Wrap(err, "error getting authority account info")
228+
}
229+
230+
if ai.Lamports == 0 {
231+
return nil
232+
}
233+
234+
bh, err := data.GetBlockchainLatestBlockhash(ctx)
235+
if err != nil {
236+
return errors.Wrap(err, "error getting latest blockhash")
237+
}
238+
239+
txn, err := transaction_util.MakeSolanaTransferTransaction(subsidizer, authority, subsidizer, ai.Lamports, bh)
240+
if err != nil {
241+
return errors.Wrap(err, "error making solana transfer transaction")
242+
}
243+
244+
err = txn.Sign(subsidizer.PrivateKey().ToBytes(), authority.PrivateKey().ToBytes())
245+
if err != nil {
246+
return errors.Wrap(err, "error signing transaction")
247+
}
248+
249+
return transaction_util.SubmitAndWaitForFinalization(ctx, data, &txn)
250+
}
251+
202252
func fundAuthority(ctx context.Context, data ocp_data.Provider, subsidizer, account *common.Account, amount uint64) error {
203253
bh, err := data.GetBlockchainLatestBlockhash(ctx)
204254
if err != nil {

ocp/worker/currency/launcher/worker.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,16 @@ func (p *runtime) handle(ctx context.Context, record *currency.MetadataRecord) e
9494

9595
var err error
9696
switch record.State {
97+
case currency.MetadataStateWaitingForInitialPurchase:
98+
err = p.handleStateWaitingForInitialPurchase(ctx, record)
9799
case currency.MetadataStateFundingAuthority:
98100
err = p.handleStateFundingAuthority(ctx, record)
99101
case currency.MetadataStateCompletingInitialization:
100102
err = p.handleStateCompletingInitialization(ctx, record)
101103
case currency.MetadataStateFinalValidation:
102104
err = p.handleStateFinalValidation(ctx, record)
105+
case currency.MetadataStateAbandoning:
106+
err = p.handleStateAbandoning(ctx, record)
103107
}
104108
if err != nil {
105109
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
108112
return nil
109113
}
110114

115+
func (p *runtime) handleStateWaitingForInitialPurchase(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord) error {
116+
err := p.validateCurrencyMetadataState(currencyMetadataRecord, currency.MetadataStateWaitingForInitialPurchase)
117+
if err != nil {
118+
return err
119+
}
120+
121+
timeout := p.conf.initialPurchaseTimeout.Get(ctx)
122+
if time.Since(currencyMetadataRecord.CreatedAt) < timeout {
123+
return nil
124+
}
125+
126+
return p.markCurrencyMetadataAbandoning(ctx, currencyMetadataRecord)
127+
}
128+
111129
// Note: Assumes unique authority per currency
112130
func (p *runtime) handleStateFundingAuthority(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord) error {
113131
err := p.validateCurrencyMetadataState(currencyMetadataRecord, currency.MetadataStateFundingAuthority)
@@ -375,3 +393,27 @@ func (p *runtime) handleStateFinalValidation(ctx context.Context, currencyMetada
375393
return p.initializeCreatorAcccount(ctx, currencyMetadataRecord, accounts)
376394
})
377395
}
396+
397+
func (p *runtime) handleStateAbandoning(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord) error {
398+
err := p.validateCurrencyMetadataState(currencyMetadataRecord, currency.MetadataStateAbandoning)
399+
if err != nil {
400+
return err
401+
}
402+
403+
authorityVaultRecord, err := p.data.GetKey(ctx, currencyMetadataRecord.Authority)
404+
if err != nil {
405+
return errors.Wrap(err, "error getting authority vault record")
406+
}
407+
408+
authority, err := common.NewAccountFromPrivateKeyString(authorityVaultRecord.PrivateKey)
409+
if err != nil {
410+
return errors.Wrap(err, "invalid authority private key")
411+
}
412+
413+
err = returnAuthorityFundsToSubsidizer(ctx, p.data, p.subsidizer, authority)
414+
if err != nil {
415+
return errors.Wrap(err, "error returning authority funds to subsidizer")
416+
}
417+
418+
return p.markCurrencyMetadataAbandoned(ctx, currencyMetadataRecord)
419+
}

0 commit comments

Comments
 (0)