├── .github ├── CODEOWNERS └── workflows │ └── CICD.yaml ├── .gitignore ├── .golangci.yml ├── LICENSE.header ├── LICENSE.txt ├── Makefile ├── README.md ├── application.yaml ├── balance-synchronizer ├── balance_synchronizer.go ├── blockchain.go └── contract.go ├── bookkeeper ├── bookkeeper.go ├── contract.go └── storage │ ├── .testdata │ ├── application.yaml │ └── docker-compose.yaml │ ├── contract.go │ ├── ddl.sql │ ├── select_total_coins.sql │ ├── storage.go │ └── storage_test.go ├── cmd ├── freezer-coin-distributer │ ├── Dockerfile │ └── freezer_coin_distributer.go ├── freezer-miner │ ├── Dockerfile │ └── freezer_miner.go ├── freezer-refrigerant │ ├── .testdata │ │ ├── application.yaml │ │ ├── expected_swagger.json │ │ ├── localhost.crt │ │ └── localhost.key │ ├── Dockerfile │ ├── api │ │ ├── docs.go │ │ ├── swagger.json │ │ └── swagger.yaml │ ├── coin_distribution.go │ ├── contract.go │ ├── freezer.go │ ├── freezer_refrigerant.go │ └── tokenomics.go └── freezer │ ├── .testdata │ ├── application.yaml │ ├── expected_swagger.json │ ├── localhost.crt │ └── localhost.key │ ├── Dockerfile │ ├── api │ ├── docs.go │ ├── swagger.json │ └── swagger.yaml │ ├── contract.go │ ├── freezer.go │ ├── statistics.go │ └── tokenomics.go ├── coin-distribution ├── .testdata │ └── application.yaml ├── DDL.sql ├── batch.go ├── client.go ├── client_test.go ├── coin_distribution.go ├── coin_distribution_test.go ├── config.go ├── contract.go ├── eligibility.go ├── eligibility_test.go ├── internal │ ├── .gitignore │ ├── Makefile │ └── ice_token.go ├── pending_review.go ├── processor.go ├── processor_test.go └── slack_alerts.go ├── extra-bonus-notifier ├── .testdata │ └── application.yaml ├── availability.go ├── availability_test.go ├── contract.go └── extra_bonus_notifier.go ├── go.mod ├── go.sum ├── local.go ├── miner ├── .testdata │ └── application.yaml ├── contract.go ├── days_off.go ├── days_off_test.go ├── ethereum_distribution.go ├── metrics.go ├── metrics_test.go ├── miner.go ├── mining.go ├── mining_test.go ├── referral_lifecycle.go ├── referral_lifecycle_test.go └── resurrection.go ├── model ├── model.go └── model_test.go └── tokenomics ├── .testdata └── application.yaml ├── adoption.go ├── balance.go ├── balance_recalculation_test.go ├── balance_test.go ├── balance_total_coins.go ├── balance_total_coins_test.go ├── contract.go ├── detailed_coin_metrics ├── .testdata │ └── application.yaml ├── api_client.go ├── api_client_test.go ├── contract.go └── repository.go ├── extra_bonus.go ├── fixture ├── contract.go └── fixture.go ├── globalDDL.sql ├── kyc.go ├── kyc_test.go ├── mining.go ├── mining_boost.go ├── mining_boost_test.go ├── mining_sessions.go ├── mining_sessions_test.go ├── mining_test.go ├── pre_staking.go ├── seeding └── seeding.go ├── tokenomics.go └── users.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ice-blockchain/golang 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.BIN 3 | *.bin 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | *.test 9 | *.out 10 | *.app 11 | *.bat 12 | *.cgi 13 | *.com 14 | *.gadget 15 | *.jar 16 | *.pif 17 | *.vb 18 | *.wsf 19 | /out 20 | vendor/ 21 | /Godeps 22 | *.iml 23 | *.ipr 24 | /.idea 25 | *.iws 26 | /.vscode 27 | .tmp-* 28 | .env 29 | *.patch 30 | -------------------------------------------------------------------------------- /LICENSE.header: -------------------------------------------------------------------------------- 1 | SPDX-License-Identifier: ice License 1.0 -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | ice License 2 | 3 | Version 1.0, January 2023 4 | 5 | ----------------------------------------------------------------------------- 6 | 7 | 8 | Licensor: ice Labs Limited 9 | 10 | Licensed Work: ice Network 11 | The Licensed Work is (c) 2023 ice Labs Limited 12 | 13 | ----------------------------------------------------------------------------- 14 | 15 | 16 | Permission is hereby granted by the application Software Developer, ice Labs 17 | Limited, free of charge, to any person obtaining a copy of this application, 18 | software, and associated documentation files (the Software), which was 19 | developed by the Software Developer (ice Labs Limited) for use on ice Network 20 | whereby the purpose of this license is to permit the development of 21 | derivative works based on the Software, including the right to use, copy, 22 | modify, merge, publish, distribute, sub-license, and/or sell copies of such 23 | derivative works and any Software components incorporated therein, and to 24 | permit persons to whom such derivative works are furnished to do so, in each 25 | case, solely to develop, use, and market applications for the official ice 26 | Network. 27 | 28 | All Derivative Works developed under this License for use on the ice Network 29 | may only be released after the official launch of the ice Network’s Mainnet. 30 | 31 | For purposes of this license, ice Network shall mean any application, 32 | software, or another present or future platform developed, owned, or managed 33 | by ice Labs Limited, and its parents, affiliates, or subsidiaries. 34 | 35 | Disclaimer of Warranty. Unless required by applicable law or agreed to in 36 | writing, Licensor provides the Software on an "AS IS" BASIS, WITHOUT 37 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, 38 | without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, 39 | MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely 40 | responsible for determining the appropriateness of using or redistributing 41 | the Software and assume any risks associated with Your exercise of 42 | permissions under this License. 43 | 44 | Limitation of Liability. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 45 | BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION 46 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH 47 | THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 48 | 49 | The above copyright notice and this permission notice shall be included in 50 | all copies or substantial portions of the Software. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Freezer Service 2 | 3 | ``Freezer is handling everything related to user's ice tokenomics and statistics about it.`` 4 | 5 | ### Development 6 | 7 | These are the crucial/critical operations you will need when developing `Freezer`: 8 | 9 | 1. If you need to generate a new Authorization Token & UserID for testing locally: 10 | 1. run `make print-token-XXX`, where `XXX` is the role you want for the user. 11 | 2. If you need to seed your local database, or even a remote one: 12 | 1. run `make start-seeding` 13 | 2. it requires an .env entry: `MASTER_DB_INSTANCE_ADDRESS=admin:pass@127.0.0.1:3301` 14 | 3. `make run-freezer` 15 | 1. This runs the actual read service. 16 | 2. It will feed off of the properties in `./application.yaml` 17 | 3. By default, https://localhost:2443/tokenomics/r runs the Open API (Swagger) entrypoint. 18 | 4. `make run-freezer-refrigerant` 19 | 1. This runs the actual write service. 20 | 2. It will feed off of the properties in `./application.yaml` 21 | 3. By default, https://localhost:3443/tokenomics/w runs the Open API (Swagger) entrypoint. 22 | 5. `make start-test-environment` 23 | 1. This bootstraps a local test environment with **Freezer**'s dependencies using your `docker` and `docker-compose` daemons. 24 | 2. It is a blocking operation, SIGTERM or SIGINT will kill it. 25 | 3. It will feed off of the properties in `./application.yaml` 26 | 1. MessageBroker GUIs 27 | 1. https://www.conduktor.io 28 | 2. https://www.kafkatool.com 29 | 3. (CLI) https://vectorized.io/redpanda 30 | 2. DB GUIs 31 | 1. https://github.com/tarantool/awesome-tarantool#gui-clients 32 | 2. (CLI) `docker exec -t -i mytarantool console` where `mytarantool` is the container name 33 | 6. `make all` 34 | 1. This runs the CI pipeline, locally -- the same pipeline that PR checks run. 35 | 2. Run it before you commit to save time & not wait for PR check to fail remotely. 36 | 7. `make local` 37 | 1. This runs the CI pipeline, in a descriptive/debug mode. Run it before you run the "real" one. 38 | 8. `make lint` 39 | 1. This runs the linters. It is a part of the other pipelines, so you can run this separately to fix lint issues. 40 | 9. `make test` 41 | 1. This runs all tests. 42 | 10. `make benchmark` 43 | 1. This runs all benchmarks. 44 | -------------------------------------------------------------------------------- /balance-synchronizer/balance_synchronizer.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package balancesynchronizer 4 | 5 | import ( 6 | "context" 7 | "sync" 8 | 9 | "github.com/goccy/go-json" 10 | "github.com/hashicorp/go-multierror" 11 | "github.com/pkg/errors" 12 | "github.com/redis/go-redis/v9" 13 | 14 | "github.com/ice-blockchain/freezer/model" 15 | appCfg "github.com/ice-blockchain/wintr/config" 16 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 17 | "github.com/ice-blockchain/wintr/connectors/storage/v3" 18 | "github.com/ice-blockchain/wintr/log" 19 | ) 20 | 21 | func init() { 22 | appCfg.MustLoadFromKey(parentApplicationYamlKey, &cfg.Config) 23 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 24 | } 25 | 26 | func MustStartSynchronizingBalance(ctx context.Context) { 27 | bs := &balanceSynchronizer{ 28 | mb: messagebroker.MustConnect(context.Background(), parentApplicationYamlKey), 29 | } 30 | defer func() { log.Panic(errors.Wrap(bs.Close(), "failed to stop balanceSynchronizer")) }() 31 | 32 | wg := new(sync.WaitGroup) 33 | wg.Add(int(cfg.Workers)) 34 | defer wg.Wait() 35 | 36 | for workerNumber := int64(0); workerNumber < cfg.Workers; workerNumber++ { 37 | go func(wn int64) { 38 | defer wg.Done() 39 | bs.synchronize(ctx, wn) 40 | }(workerNumber) 41 | } 42 | } 43 | 44 | func (bs *balanceSynchronizer) Close() error { 45 | return multierror.Append( 46 | errors.Wrap(bs.mb.Close(), "failed to close mb"), 47 | ).ErrorOrNil() 48 | } 49 | 50 | func (bs *balanceSynchronizer) synchronize(ctx context.Context, workerNumber int64) { 51 | db := storage.MustConnect(context.Background(), parentApplicationYamlKey, 1) 52 | defer func() { 53 | if err := recover(); err != nil { 54 | log.Error(db.Close()) 55 | panic(err) 56 | } 57 | log.Error(db.Close()) 58 | }() 59 | var ( 60 | batchNumber int64 61 | iteration uint64 62 | workers = cfg.Workers 63 | batchSize = cfg.BatchSize 64 | userKeys = make([]string, 0, batchSize) 65 | userResults = make([]*user, 0, batchSize) 66 | msgResponder = make(chan error, batchSize) 67 | msgs = make([]*messagebroker.Message, 0, batchSize) 68 | errs = make([]error, 0, batchSize) 69 | updatedUsers = make([]redis.Z, 0, batchSize) 70 | blockchainMessages = make([]*blockchainMessage, 0, batchSize) 71 | ) 72 | resetVars := func(success bool) { 73 | if success && len(userResults) < int(batchSize) { 74 | batchNumber = 0 75 | iteration++ 76 | } 77 | userKeys = userKeys[:0] 78 | userResults = userResults[:0] 79 | msgs, errs = msgs[:0], errs[:0] 80 | updatedUsers = updatedUsers[:0] 81 | blockchainMessages = blockchainMessages[:0] 82 | } 83 | for ctx.Err() == nil { 84 | /****************************************************************************************************************************************************** 85 | 1. Fetching a new batch of users. 86 | ******************************************************************************************************************************************************/ 87 | if len(userKeys) == 0 { 88 | for ix := batchNumber * batchSize; ix < (batchNumber+1)*batchSize; ix++ { 89 | userKeys = append(userKeys, model.SerializedUsersKey((workers*ix)+workerNumber)) 90 | } 91 | } 92 | reqCtx, reqCancel := context.WithTimeout(context.Background(), requestDeadline) 93 | if err := storage.Bind[user](reqCtx, db, userKeys, &userResults); err != nil { 94 | log.Error(errors.Wrapf(err, "[balanceSynchronizer] failed to get users for batchNumer:%v,workerNumber:%v", batchNumber, workerNumber)) 95 | reqCancel() 96 | 97 | continue 98 | } 99 | reqCancel() 100 | 101 | /****************************************************************************************************************************************************** 102 | 2. Processing batch. 103 | ******************************************************************************************************************************************************/ 104 | 105 | for _, usr := range userResults { 106 | updatedUsers = append(updatedUsers, GlobalRank(usr.ID, usr.BalanceTotalStandard+usr.BalanceTotalPreStaking)) 107 | msgs = append(msgs, BalanceUpdatedMessage(ctx, usr.UserID, usr.BalanceTotalStandard, usr.BalanceTotalPreStaking)) 108 | if msg := shouldSynchronizeBlockchainAccount(iteration, usr); msg != nil { 109 | blockchainMessages = append(blockchainMessages, msg) 110 | } 111 | } 112 | 113 | /****************************************************************************************************************************************************** 114 | 3. Sending messages to the broker. 115 | ******************************************************************************************************************************************************/ 116 | 117 | reqCtx, reqCancel = context.WithTimeout(context.Background(), requestDeadline) 118 | for _, message := range msgs { 119 | bs.mb.SendMessage(reqCtx, message, msgResponder) 120 | } 121 | for (len(msgs) > 0 && len(errs) < len(msgs)) || len(msgResponder) > 0 { 122 | errs = append(errs, <-msgResponder) 123 | } 124 | if err := multierror.Append(reqCtx.Err(), errs...).ErrorOrNil(); err != nil { 125 | log.Error(errors.Wrapf(err, "[balanceSynchronizer] failed to send messages to broker for batchNumer:%v,workerNumber:%v", batchNumber, workerNumber)) 126 | reqCancel() 127 | resetVars(false) 128 | 129 | continue 130 | } 131 | reqCancel() 132 | 133 | /****************************************************************************************************************************************************** 134 | 4. Updating user scores in `top_miners` sorted set. 135 | ******************************************************************************************************************************************************/ 136 | 137 | if len(updatedUsers) > 0 { 138 | reqCtx, reqCancel = context.WithTimeout(context.Background(), requestDeadline) 139 | if err := db.ZAdd(reqCtx, "top_miners", updatedUsers...).Err(); err != nil { 140 | log.Error(errors.Wrapf(err, "[balanceSynchronizer] failed to ZAdd top_miners for batchNumer:%v,workerNumber:%v", batchNumber, workerNumber)) 141 | reqCancel() 142 | resetVars(false) 143 | 144 | continue 145 | } 146 | reqCancel() 147 | } 148 | 149 | /****************************************************************************************************************************************************** 150 | 5. Updating balances in the blockchain for that batch of users. 151 | ******************************************************************************************************************************************************/ 152 | 153 | reqCtx, reqCancel = context.WithTimeout(context.Background(), requestDeadline) 154 | if err := bs.synchronizeBlockchainAccounts(reqCtx, blockchainMessages); err != nil { 155 | log.Error(errors.Wrapf(err, "[balanceSynchronizer] failed to synchronizeBlockchainAccount for batchNumer:%v,workerNumber:%v", batchNumber, workerNumber)) 156 | reqCancel() 157 | resetVars(false) 158 | 159 | continue 160 | } 161 | 162 | batchNumber++ 163 | reqCancel() 164 | resetVars(true) 165 | } 166 | 167 | } 168 | 169 | func GlobalRank(id int64, totalBalance float64) redis.Z { 170 | return redis.Z{ 171 | Score: totalBalance, 172 | Member: model.SerializedUsersKey(id), 173 | } 174 | } 175 | 176 | func BalanceUpdatedMessage( 177 | ctx context.Context, userID string, totalStandardBalance, totalPreStakingBalance float64, 178 | ) *messagebroker.Message { 179 | event := &BalanceUpdated{ 180 | UserID: userID, 181 | Standard: totalStandardBalance, 182 | PreStaking: totalPreStakingBalance, 183 | } 184 | valueBytes, err := json.MarshalContext(ctx, event) 185 | log.Panic(errors.Wrapf(err, "failed to marshal %#v", event)) 186 | 187 | return &messagebroker.Message{ 188 | Headers: map[string]string{"producer": "freezer"}, 189 | Key: event.UserID, 190 | Topic: cfg.MessageBroker.Topics[3].Name, 191 | Value: valueBytes, 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /balance-synchronizer/blockchain.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package balancesynchronizer 4 | 5 | import ( 6 | "context" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/ice-blockchain/wintr/coin" 11 | ) 12 | 13 | type ( 14 | blockchainMessage struct { // TODO: delete this and use the actual one. 15 | AccountAddress string 16 | ICEFlake string 17 | PreStakingICEFlake string 18 | } 19 | ) 20 | 21 | func shouldSynchronizeBlockchainAccount(iteration uint64, usr *user) *blockchainMessage { 22 | if usr.MiningBlockchainAccountAddress == "" || iteration%100 != 0 { 23 | return nil 24 | } 25 | var standard, preStaking *coin.ICEFlake 26 | if standardParts := strings.Split(strconv.FormatFloat(usr.BalanceTotalStandard, 'f', 9, 64), "."); len(standardParts) == 1 { 27 | standard = coin.UnsafeParseAmount(standardParts[0]).MultiplyUint64(coin.Denomination) 28 | } else if len(standardParts) == 2 { 29 | standard = coin.UnsafeParseAmount(standardParts[0]).MultiplyUint64(coin.Denomination).Add(coin.UnsafeParseAmount(standardParts[1])) 30 | } 31 | if preStakingParts := strings.Split(strconv.FormatFloat(usr.BalanceTotalPreStaking, 'f', 9, 64), "."); len(preStakingParts) == 1 { 32 | preStaking = coin.UnsafeParseAmount(preStakingParts[0]).MultiplyUint64(coin.Denomination) 33 | } else if len(preStakingParts) == 2 { 34 | preStaking = coin.UnsafeParseAmount(preStakingParts[0]).MultiplyUint64(coin.Denomination).Add(coin.UnsafeParseAmount(preStakingParts[1])) 35 | } 36 | 37 | return &blockchainMessage{ 38 | AccountAddress: usr.MiningBlockchainAccountAddress, 39 | ICEFlake: coin.ZeroICEFlakes().Add(standard).String(), 40 | PreStakingICEFlake: coin.ZeroICEFlakes().Add(preStaking).String(), 41 | } 42 | } 43 | 44 | func (bs *balanceSynchronizer) synchronizeBlockchainAccounts(ctx context.Context, msgs []*blockchainMessage) error { 45 | if len(msgs) == 0 || ctx.Err() != nil { 46 | return nil 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /balance-synchronizer/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package balancesynchronizer 4 | 5 | import ( 6 | stdlibtime "time" 7 | 8 | "github.com/ice-blockchain/freezer/model" 9 | "github.com/ice-blockchain/freezer/tokenomics" 10 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 11 | ) 12 | 13 | // Public API. 14 | 15 | type ( 16 | BalanceUpdated struct { 17 | UserID string `json:"userId,omitempty"` 18 | Standard float64 `json:"standard,omitempty"` 19 | PreStaking float64 `json:"preStaking,omitempty"` 20 | } 21 | ) 22 | 23 | // Private API. 24 | 25 | const ( 26 | applicationYamlKey = "balance-synchronizer" 27 | parentApplicationYamlKey = "tokenomics" 28 | requestDeadline = 30 * stdlibtime.Second 29 | ) 30 | 31 | // . 32 | var ( 33 | //nolint:gochecknoglobals // Singleton & global config mounted only during bootstrap. 34 | cfg struct { 35 | tokenomics.Config `mapstructure:",squash"` //nolint:tagliatelle // Nope. 36 | Workers int64 `yaml:"workers"` 37 | BatchSize int64 `yaml:"batchSize"` 38 | } 39 | ) 40 | 41 | type ( 42 | user struct { 43 | model.UserIDField 44 | model.MiningBlockchainAccountAddressField 45 | model.DeserializedUsersKey 46 | model.BalanceTotalStandardField 47 | model.BalanceTotalPreStakingField 48 | } 49 | 50 | balanceSynchronizer struct { 51 | mb messagebroker.Client 52 | } 53 | ) 54 | -------------------------------------------------------------------------------- /bookkeeper/bookkeeper.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package bookkeeper 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "sync" 9 | stdlibtime "time" 10 | 11 | "github.com/hashicorp/go-multierror" 12 | "github.com/pkg/errors" 13 | "github.com/redis/go-redis/v9" 14 | 15 | dwh "github.com/ice-blockchain/freezer/bookkeeper/storage" 16 | "github.com/ice-blockchain/freezer/model" 17 | appCfg "github.com/ice-blockchain/wintr/config" 18 | "github.com/ice-blockchain/wintr/connectors/storage/v3" 19 | "github.com/ice-blockchain/wintr/log" 20 | ) 21 | 22 | func init() { 23 | appCfg.MustLoadFromKey(parentApplicationYamlKey, &cfg.Config) 24 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 25 | } 26 | 27 | func MustStartBookkeeping(ctx context.Context) { 28 | bk := &bookkeeper{} 29 | defer func() { log.Panic(errors.Wrap(bk.Close(), "failed to stop bookkeeper")) }() 30 | 31 | wg := new(sync.WaitGroup) 32 | wg.Add(int(cfg.Workers)) 33 | defer wg.Wait() 34 | 35 | for workerNumber := int64(0); workerNumber < cfg.Workers; workerNumber++ { 36 | go func(wn int64) { 37 | defer wg.Done() 38 | bk.bookKeep(ctx, wn) 39 | }(workerNumber) 40 | } 41 | } 42 | 43 | func (bk *bookkeeper) Close() error { 44 | return nil 45 | } 46 | 47 | func (bk *bookkeeper) bookKeep(ctx context.Context, workerNumber int64) { 48 | db := storage.MustConnect(context.Background(), parentApplicationYamlKey, 1) 49 | defer func() { 50 | if err := recover(); err != nil { 51 | log.Error(db.Close()) 52 | panic(err) 53 | } 54 | log.Error(db.Close()) 55 | }() 56 | dwhClient := dwh.MustConnect(context.Background(), applicationYamlKey) 57 | defer func() { 58 | if err := recover(); err != nil { 59 | log.Error(dwhClient.Close()) 60 | panic(err) 61 | } 62 | log.Error(dwhClient.Close()) 63 | }() 64 | var ( 65 | historyKey = fmt.Sprintf("user_historical_chunks:%v", workerNumber) 66 | userResults = make([]*model.User, 0, cfg.BatchSize) 67 | errs = make([]error, 0, cfg.BatchSize) 68 | historyColumns, historyInsertMetadata = dwh.InsertDDL(int(cfg.BatchSize)) 69 | ) 70 | for ctx.Err() == nil { 71 | /****************************************************************************************************************************************************** 72 | 1. Fetching a new batch of users. 73 | ******************************************************************************************************************************************************/ 74 | 75 | reqCtx, reqCancel := context.WithTimeout(context.Background(), requestDeadline) 76 | userKeys, err := db.LRange(reqCtx, historyKey, 0, cfg.BatchSize-1).Result() 77 | reqCancel() 78 | 79 | if err != nil || len(userKeys) == 0 { 80 | log.Error(errors.Wrapf(err, "[bookkeeper] failed to LRange for users for workerNumber:%v", workerNumber)) 81 | stdlibtime.Sleep(stdlibtime.Duration(10*workerNumber) * stdlibtime.Millisecond) 82 | 83 | continue 84 | } 85 | 86 | /****************************************************************************************************************************************************** 87 | 2. Getting the historical data for that batch. 88 | ******************************************************************************************************************************************************/ 89 | 90 | if len(userResults) != 0 { 91 | userResults = userResults[:0] 92 | } 93 | reqCtx, reqCancel = context.WithTimeout(context.Background(), requestDeadline) 94 | err = storage.Bind[model.User](reqCtx, db, userKeys, &userResults) 95 | reqCancel() 96 | 97 | if err != nil { 98 | log.Error(errors.Wrapf(err, "[bookkeeper] failed to get users for workerNumber:%v", workerNumber)) 99 | 100 | continue 101 | } 102 | 103 | /****************************************************************************************************************************************************** 104 | 3. Sending data to analytics/dwh storage. 105 | ******************************************************************************************************************************************************/ 106 | 107 | reqCtx, reqCancel = context.WithTimeout(context.Background(), requestDeadline) 108 | err = dwhClient.Insert(reqCtx, historyColumns, historyInsertMetadata, userResults) 109 | reqCancel() 110 | 111 | if err != nil { 112 | log.Error(errors.Wrapf(err, "[bookkeeper] failed to XXXXX for workerNumber:%v", workerNumber)) 113 | 114 | continue 115 | } 116 | 117 | /****************************************************************************************************************************************************** 118 | 4. Deleting historical data from originating storage. 119 | ******************************************************************************************************************************************************/ 120 | 121 | reqCtx, reqCancel = context.WithTimeout(context.Background(), requestDeadline) 122 | responses, err := db.Pipelined(reqCtx, func(pipeliner redis.Pipeliner) error { 123 | return multierror.Append( //nolint:wrapcheck // Not needed. 124 | pipeliner.Del(reqCtx, userKeys...).Err(), 125 | pipeliner.LPopCount(reqCtx, historyKey, len(userKeys)).Err(), 126 | ).ErrorOrNil() 127 | }) 128 | reqCancel() 129 | 130 | if len(errs) != 0 { 131 | errs = errs[:0] 132 | } 133 | for _, response := range responses { 134 | if rErr := response.Err(); rErr != nil { 135 | errs = append(errs, errors.Wrapf(rErr, "failed to `%v`", response.FullName())) 136 | } 137 | } 138 | if rErr := multierror.Append(err, errs...).ErrorOrNil(); rErr != nil { 139 | log.Error(errors.Wrapf(rErr, "[bookkeeper] failed to del originating historical data for workerNumber:%v", workerNumber)) 140 | 141 | continue 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /bookkeeper/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package bookkeeper 4 | 5 | import ( 6 | stdlibtime "time" 7 | 8 | "github.com/ice-blockchain/freezer/tokenomics" 9 | ) 10 | 11 | // Private API. 12 | 13 | const ( 14 | applicationYamlKey = "bookkeeper" 15 | parentApplicationYamlKey = "tokenomics" 16 | requestDeadline = 30 * stdlibtime.Second 17 | ) 18 | 19 | // . 20 | var ( 21 | //nolint:gochecknoglobals // Singleton & global config mounted only during bootstrap. 22 | cfg struct { 23 | tokenomics.Config `mapstructure:",squash"` //nolint:tagliatelle // Nope. 24 | Workers int64 `yaml:"workers"` 25 | BatchSize int64 `yaml:"batchSize"` 26 | } 27 | ) 28 | 29 | type ( 30 | bookkeeper struct{} 31 | ) 32 | -------------------------------------------------------------------------------- /bookkeeper/storage/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: debug 7 | self: 8 | bookkeeper/storage: 9 | runDDL: true 10 | urls: 11 | - localhost:9000 12 | db: default 13 | poolSize: 1 14 | credentials: 15 | user: default 16 | password: 17 | 18 | -------------------------------------------------------------------------------- /bookkeeper/storage/.testdata/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BUSL-1.1 2 | 3 | version: '3.7' 4 | 5 | services: 6 | freezer_clickhouse: 7 | image: clickhouse/clickhouse-server:latest 8 | pull_policy: always 9 | ulimits: 10 | nofile: 11 | soft: 262144 12 | hard: 262144 13 | ports: 14 | - "9000:9000" 15 | - "8123:8123" 16 | -------------------------------------------------------------------------------- /bookkeeper/storage/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package storage 4 | 5 | import ( 6 | "context" 7 | _ "embed" 8 | "io" 9 | stdlibtime "time" 10 | 11 | "github.com/ClickHouse/ch-go" 12 | "github.com/ClickHouse/ch-go/chpool" 13 | "github.com/ClickHouse/ch-go/proto" 14 | 15 | "github.com/ice-blockchain/freezer/model" 16 | "github.com/ice-blockchain/wintr/time" 17 | ) 18 | 19 | // Public API. 20 | 21 | type ( 22 | Client interface { 23 | io.Closer 24 | Ping(ctx context.Context) error 25 | Insert(ctx context.Context, columns *Columns, input InsertMetadata, usrs []*model.User) error 26 | SelectBalanceHistory(ctx context.Context, id int64, createdAts []stdlibtime.Time) ([]*BalanceHistory, error) 27 | SelectTotalCoins(ctx context.Context, createdAtTime stdlibtime.Time, parentInverval stdlibtime.Duration) ([]*TotalCoins, error) 28 | DeleteUserInfo(ctx context.Context, id int64) error 29 | } 30 | BalanceHistory struct { 31 | CreatedAt *time.Time 32 | BalanceTotalMinted, BalanceTotalSlashed float64 33 | } 34 | TotalCoins struct { 35 | CreatedAt *time.Time `redis:"created_at"` 36 | BalanceTotalStandard float64 `redis:"standard"` 37 | BalanceTotalPreStaking float64 `redis:"pre_staking"` 38 | BalanceTotalEthereum float64 `redis:"blockchain"` 39 | BalanceTotal float64 `redis:"total"` 40 | } 41 | InsertMetadata = proto.Input 42 | Columns struct { 43 | miningSessionSoloLastStartedAt *proto.ColDateTime64 44 | miningSessionSoloStartedAt *proto.ColDateTime64 45 | miningSessionSoloEndedAt *proto.ColDateTime64 46 | miningSessionSoloPreviouslyEndedAt *proto.ColDateTime64 47 | extraBonusStartedAt *proto.ColDateTime64 48 | resurrectSoloUsedAt *proto.ColDateTime64 49 | resurrectT0UsedAt *proto.ColDateTime64 50 | resurrectTminus1UsedAt *proto.ColDateTime64 51 | miningSessionSoloDayOffLastAwardedAt *proto.ColDateTime64 52 | extraBonusLastClaimAvailableAt *proto.ColDateTime64 53 | soloLastEthereumCoinDistributionProcessedAt *proto.ColDateTime64 54 | forT0LastEthereumCoinDistributionProcessedAt *proto.ColDateTime64 55 | forTMinus1LastEthereumCoinDistributionProcessedAt *proto.ColDateTime64 56 | balanceLastUpdatedAt *proto.ColDateTime64 57 | createdAt *proto.ColDateTime 58 | country *proto.ColStr 59 | profilePictureName *proto.ColStr 60 | username *proto.ColStr 61 | miningBlockchainAccountAddress *proto.ColStr 62 | blockchainAccountAddress *proto.ColStr 63 | userID *proto.ColStr 64 | id *proto.ColInt64 65 | idT0 *proto.ColInt64 66 | idTminus1 *proto.ColInt64 67 | balanceTotalStandard *proto.ColFloat64 68 | balanceTotalPreStaking *proto.ColFloat64 69 | balanceTotalMinted *proto.ColFloat64 70 | balanceTotalSlashed *proto.ColFloat64 71 | balanceSoloPending *proto.ColFloat64 72 | balanceT1Pending *proto.ColFloat64 73 | balanceT2Pending *proto.ColFloat64 74 | balanceSoloPendingApplied *proto.ColFloat64 75 | balanceT1PendingApplied *proto.ColFloat64 76 | balanceT2PendingApplied *proto.ColFloat64 77 | balanceSolo *proto.ColFloat64 78 | balanceT0 *proto.ColFloat64 79 | balanceT1 *proto.ColFloat64 80 | balanceT2 *proto.ColFloat64 81 | balanceForT0 *proto.ColFloat64 82 | balanceForTminus1 *proto.ColFloat64 83 | balanceSoloEthereum *proto.ColFloat64 84 | balanceT0Ethereum *proto.ColFloat64 85 | balanceT1Ethereum *proto.ColFloat64 86 | balanceT2Ethereum *proto.ColFloat64 87 | balanceForT0Ethereum *proto.ColFloat64 88 | balanceForTMinus1Ethereum *proto.ColFloat64 89 | balanceSoloEthereumMainnetRewardPoolContribution *proto.ColFloat64 90 | balanceT0EthereumMainnetRewardPoolContribution *proto.ColFloat64 91 | balanceT1EthereumMainnetRewardPoolContribution *proto.ColFloat64 92 | balanceT2EthereumMainnetRewardPoolContribution *proto.ColFloat64 93 | balanceForT0EthereumMainnetRewardPoolContribution *proto.ColFloat64 94 | balanceForTMinus1EthereumMainnetRewardPoolContribution *proto.ColFloat64 95 | slashingRateSolo *proto.ColFloat64 96 | slashingRateT0 *proto.ColFloat64 97 | slashingRateT1 *proto.ColFloat64 98 | slashingRateT2 *proto.ColFloat64 99 | slashingRateForT0 *proto.ColFloat64 100 | slashingRateForTminus1 *proto.ColFloat64 101 | activeT1Referrals *proto.ColInt32 102 | activeT2Referrals *proto.ColInt32 103 | preStakingBonus *proto.ColUInt16 104 | preStakingAllocation *proto.ColUInt16 105 | extraBonus *proto.ColUInt16 106 | newsSeen *proto.ColUInt16 107 | extraBonusDaysClaimNotAvailable *proto.ColUInt16 108 | utcOffset *proto.ColInt16 109 | kycStepPassed *proto.ColUInt8 110 | kycStepBlocked *proto.ColUInt8 111 | kycQuizCompleted *proto.ColBool 112 | kycQuizDisabled *proto.ColBool 113 | hideRanking *proto.ColBool 114 | kycStepsCreatedAt *proto.ColArr[stdlibtime.Time] 115 | kycStepsLastUpdatedAt *proto.ColArr[stdlibtime.Time] 116 | } 117 | ) 118 | 119 | // Private API. 120 | 121 | const ( 122 | tableName = "freezer_user_history" 123 | ) 124 | 125 | // . 126 | var ( 127 | //go:embed ddl.sql 128 | ddl string 129 | 130 | //go:embed select_total_coins.sql 131 | selectTotalCoinsSQL string 132 | ) 133 | 134 | type ( 135 | db struct { 136 | cfg *config 137 | pools []*chpool.Pool 138 | settings []ch.Setting 139 | currentIndex uint64 140 | } 141 | config struct { 142 | Storage struct { 143 | Credentials struct { 144 | User string `yaml:"user"` 145 | Password string `yaml:"password"` 146 | } `yaml:"credentials" mapstructure:"credentials"` 147 | DB string `yaml:"db" mapstructure:"db"` 148 | URLs []string `yaml:"urls" mapstructure:"urls"` 149 | PoolSize int32 `yaml:"poolSize" mapstructure:"poolSize"` 150 | RunDDL bool `yaml:"runDDL" mapstructure:"runDDL"` 151 | } `yaml:"bookkeeper/storage" mapstructure:"bookkeeper/storage"` 152 | Development bool `yaml:"development" mapstructure:"development"` 153 | } 154 | ) 155 | -------------------------------------------------------------------------------- /bookkeeper/storage/select_total_coins.sql: -------------------------------------------------------------------------------- 1 | -- SPDX-License-Identifier: ice License 1.0 2 | WITH req_dates AS ( 3 | SELECT req_date FROM VALUES('req_date DateTime',('%[4]v')) 4 | ), 5 | active_users AS ( 6 | SELECT 7 | req_date AS created_at, 8 | id, id_t0, id_tminus1, pre_staking_allocation, pre_staking_bonus, balance_solo, balance_solo_ethereum, balance_t0, balance_t0_ethereum, balance_for_t0, balance_t1_ethereum 9 | FROM ( 10 | SELECT DISTINCT ON (id, created_at) 11 | created_at, id, id_t0, id_tminus1, pre_staking_allocation, pre_staking_bonus, balance_solo, balance_solo_ethereum, balance_t0, balance_t0_ethereum, balance_for_t0, balance_t1_ethereum 12 | FROM %[1]v 13 | WHERE created_at >= '%[2]v' AND created_at < '%[6]v' 14 | AND kyc_step_passed >= %[3]v 15 | AND (kyc_step_blocked = 0 OR kyc_step_blocked >= %[3]v + 1) 16 | ) t, req_dates 17 | ), 18 | valid_users_stopped_processing AS ( 19 | SELECT req_date AS created_at, 20 | id, id_t0, id_tminus1, pre_staking_allocation, pre_staking_bonus, balance_solo, balance_solo_ethereum, balance_t0, balance_t0_ethereum, balance_for_t0, balance_t1_ethereum 21 | FROM (SELECT DISTINCT ON (id, created_at) 22 | created_at, 23 | id, id_t0, id_tminus1, pre_staking_allocation, pre_staking_bonus, balance_solo, balance_solo_ethereum, balance_t0, balance_t0_ethereum, balance_for_t0, balance_t1_ethereum 24 | FROM %[1]v 25 | WHERE (id, created_at) GLOBAL IN ( 26 | SELECT id, max(created_at) 27 | FROM %[1]v 28 | WHERE kyc_step_passed >= %[3]v 29 | AND (kyc_step_blocked = 0 OR kyc_step_blocked >= %[3]v + 1) 30 | GROUP BY id 31 | HAVING max(created_at) < '%[5]v')) t, req_dates WHERE t.created_at < req_dates.req_date 32 | ), 33 | valid_users AS ( 34 | select * from (SELECT active_users.* FROM active_users UNION ALL SELECT valid_users_stopped_processing.* FROM valid_users_stopped_processing) t LIMIT 1 BY id, created_at 35 | ), 36 | valid_t1_users AS ( 37 | SELECT created_at, id_t0, SUM(balance_for_t0) AS balance_t1 38 | FROM valid_users 39 | GROUP BY created_at, id_t0 40 | ) 41 | SELECT 42 | u.created_at AS created_at, 43 | SUM(((u.balance_solo+IF(t0.id != 0, u.balance_t0, 0)+t1.balance_t1) * (100.0 - u.pre_staking_allocation)) / 100.0) AS balance_total_standard, 44 | SUM(((u.balance_solo+IF(t0.id != 0, u.balance_t0, 0)+t1.balance_t1) * (100.0 + u.pre_staking_bonus) * u.pre_staking_allocation) / 10000.0) AS balance_total_pre_staking, 45 | SUM(u.balance_solo_ethereum+u.balance_t0_ethereum+u.balance_t1_ethereum) AS balance_total_ethereum 46 | FROM valid_users u 47 | 48 | GLOBAL LEFT JOIN valid_users t0 49 | ON t0.id = u.id_t0 50 | AND t0.created_at = u.created_at 51 | GLOBAL LEFT JOIN valid_t1_users t1 52 | ON t1.id_t0 = u.id 53 | AND t1.created_at = u.created_at 54 | GROUP BY u.created_at -------------------------------------------------------------------------------- /bookkeeper/storage/storage_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package storage 4 | 5 | import ( 6 | "context" 7 | "sort" 8 | "testing" 9 | stdlibtime "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/ice-blockchain/freezer/model" 15 | "github.com/ice-blockchain/wintr/time" 16 | ) 17 | 18 | func TestStorage(t *testing.T) { 19 | cl := MustConnect(context.Background(), "self") 20 | defer func() { 21 | if err := recover(); err != nil { 22 | cl.Close() 23 | panic(err) 24 | } 25 | cl.Close() 26 | }() 27 | require.NoError(t, cl.Ping(context.Background())) 28 | t1, t2 := stdlibtime.Now().UTC().Truncate(stdlibtime.Minute), stdlibtime.Now().UTC().Add(stdlibtime.Hour).Truncate(stdlibtime.Minute) 29 | id1, id2 := t1.UnixNano(), t2.UnixNano() 30 | columns, input := InsertDDL(2) 31 | 32 | usrs := []*model.User{ 33 | { 34 | BalanceLastUpdatedAtField: model.BalanceLastUpdatedAtField{BalanceLastUpdatedAt: time.New(t1)}, 35 | MiningSessionSoloLastStartedAtField: model.MiningSessionSoloLastStartedAtField{MiningSessionSoloLastStartedAt: time.Now()}, 36 | MiningSessionSoloStartedAtField: model.MiningSessionSoloStartedAtField{MiningSessionSoloStartedAt: time.Now()}, 37 | MiningSessionSoloEndedAtField: model.MiningSessionSoloEndedAtField{MiningSessionSoloEndedAt: time.Now()}, 38 | MiningSessionSoloPreviouslyEndedAtField: model.MiningSessionSoloPreviouslyEndedAtField{MiningSessionSoloPreviouslyEndedAt: time.Now()}, 39 | ExtraBonusStartedAtField: model.ExtraBonusStartedAtField{ExtraBonusStartedAt: time.Now()}, 40 | ResurrectSoloUsedAtField: model.ResurrectSoloUsedAtField{ResurrectSoloUsedAt: time.Now()}, 41 | ResurrectT0UsedAtField: model.ResurrectT0UsedAtField{ResurrectT0UsedAt: time.Now()}, 42 | ResurrectTMinus1UsedAtField: model.ResurrectTMinus1UsedAtField{ResurrectTMinus1UsedAt: time.Now()}, 43 | MiningSessionSoloDayOffLastAwardedAtField: model.MiningSessionSoloDayOffLastAwardedAtField{MiningSessionSoloDayOffLastAwardedAt: time.Now()}, 44 | ExtraBonusLastClaimAvailableAtField: model.ExtraBonusLastClaimAvailableAtField{ExtraBonusLastClaimAvailableAt: time.Now()}, 45 | ProfilePictureNameField: model.ProfilePictureNameField{ProfilePictureName: "ProfilePictureName"}, 46 | UsernameField: model.UsernameField{Username: "Username"}, 47 | MiningBlockchainAccountAddressField: model.MiningBlockchainAccountAddressField{MiningBlockchainAccountAddress: "MiningBlockchainAccountAddress"}, 48 | BlockchainAccountAddressField: model.BlockchainAccountAddressField{BlockchainAccountAddress: "BlockchainAccountAddress"}, 49 | UserIDField: model.UserIDField{UserID: "UserID"}, 50 | DeserializedUsersKey: model.DeserializedUsersKey{ID: id1}, 51 | BalanceTotalStandardField: model.BalanceTotalStandardField{BalanceTotalStandard: 1}, 52 | BalanceTotalPreStakingField: model.BalanceTotalPreStakingField{BalanceTotalPreStaking: 2}, 53 | BalanceTotalMintedField: model.BalanceTotalMintedField{BalanceTotalMinted: 3}, 54 | BalanceTotalSlashedField: model.BalanceTotalSlashedField{BalanceTotalSlashed: 4}, 55 | BalanceSoloPendingField: model.BalanceSoloPendingField{BalanceSoloPending: 5}, 56 | BalanceT1PendingField: model.BalanceT1PendingField{BalanceT1Pending: 6}, 57 | BalanceT2PendingField: model.BalanceT2PendingField{BalanceT2Pending: 7}, 58 | BalanceSoloPendingAppliedField: model.BalanceSoloPendingAppliedField{BalanceSoloPendingApplied: 8}, 59 | BalanceT1PendingAppliedField: model.BalanceT1PendingAppliedField{BalanceT1PendingApplied: 9}, 60 | BalanceT2PendingAppliedField: model.BalanceT2PendingAppliedField{BalanceT2PendingApplied: 10}, 61 | BalanceSoloField: model.BalanceSoloField{BalanceSolo: 11}, 62 | BalanceT0Field: model.BalanceT0Field{BalanceT0: 12}, 63 | BalanceT1Field: model.BalanceT1Field{BalanceT1: 13}, 64 | BalanceT2Field: model.BalanceT2Field{BalanceT2: 14}, 65 | BalanceForT0Field: model.BalanceForT0Field{BalanceForT0: 15}, 66 | BalanceForTMinus1Field: model.BalanceForTMinus1Field{BalanceForTMinus1: 16}, 67 | SlashingRateSoloField: model.SlashingRateSoloField{SlashingRateSolo: 17}, 68 | SlashingRateT0Field: model.SlashingRateT0Field{SlashingRateT0: 18}, 69 | SlashingRateT1Field: model.SlashingRateT1Field{SlashingRateT1: 19}, 70 | SlashingRateT2Field: model.SlashingRateT2Field{SlashingRateT2: 20}, 71 | SlashingRateForT0Field: model.SlashingRateForT0Field{SlashingRateForT0: 21}, 72 | SlashingRateForTMinus1Field: model.SlashingRateForTMinus1Field{SlashingRateForTMinus1: 22}, 73 | IDT0Field: model.IDT0Field{IDT0: 23}, 74 | IDTMinus1Field: model.IDTMinus1Field{IDTMinus1: 24}, 75 | ActiveT1ReferralsField: model.ActiveT1ReferralsField{ActiveT1Referrals: 25}, 76 | ActiveT2ReferralsField: model.ActiveT2ReferralsField{ActiveT2Referrals: 26}, 77 | PreStakingBonusField: model.PreStakingBonusField{PreStakingBonus: 27}, 78 | PreStakingAllocationField: model.PreStakingAllocationField{PreStakingAllocation: 28}, 79 | ExtraBonusField: model.ExtraBonusField{ExtraBonus: 29}, 80 | NewsSeenField: model.NewsSeenField{NewsSeen: 30}, 81 | ExtraBonusDaysClaimNotAvailableField: model.ExtraBonusDaysClaimNotAvailableField{ExtraBonusDaysClaimNotAvailable: 31}, 82 | UTCOffsetField: model.UTCOffsetField{UTCOffset: -32}, 83 | HideRankingField: model.HideRankingField{HideRanking: true}, 84 | }, { 85 | DeserializedUsersKey: model.DeserializedUsersKey{ID: id2}, 86 | BalanceLastUpdatedAtField: model.BalanceLastUpdatedAtField{BalanceLastUpdatedAt: time.New(t1)}, 87 | BalanceTotalMintedField: model.BalanceTotalMintedField{BalanceTotalMinted: 33}, 88 | BalanceTotalSlashedField: model.BalanceTotalSlashedField{BalanceTotalSlashed: 44}, 89 | }, 90 | } 91 | require.NoError(t, cl.Insert(context.Background(), columns, input, usrs)) 92 | 93 | usrs = []*model.User{ 94 | { 95 | DeserializedUsersKey: model.DeserializedUsersKey{ID: id1}, 96 | BalanceLastUpdatedAtField: model.BalanceLastUpdatedAtField{BalanceLastUpdatedAt: time.New(t2)}, 97 | BalanceTotalMintedField: model.BalanceTotalMintedField{BalanceTotalMinted: 333}, 98 | BalanceTotalSlashedField: model.BalanceTotalSlashedField{BalanceTotalSlashed: 444}, 99 | }, 100 | { 101 | DeserializedUsersKey: model.DeserializedUsersKey{ID: id2}, 102 | BalanceLastUpdatedAtField: model.BalanceLastUpdatedAtField{BalanceLastUpdatedAt: time.New(t2)}, 103 | BalanceTotalMintedField: model.BalanceTotalMintedField{BalanceTotalMinted: 3333}, 104 | BalanceTotalSlashedField: model.BalanceTotalSlashedField{BalanceTotalSlashed: 4444}, 105 | }, 106 | } 107 | require.NoError(t, cl.Insert(context.Background(), columns, input, usrs)) 108 | 109 | h1, err := cl.SelectBalanceHistory(context.Background(), id1, []stdlibtime.Time{t1, t2}) 110 | require.NoError(t, err) 111 | sort.SliceStable(h1, func(ii, jj int) bool { return h1[ii].CreatedAt.Before(*h1[jj].CreatedAt.Time) }) 112 | assert.EqualValues(t, []*BalanceHistory{}, h1) 113 | h2, err := cl.SelectBalanceHistory(context.Background(), id2, []stdlibtime.Time{t1, t2}) 114 | require.NoError(t, err) 115 | sort.SliceStable(h2, func(ii, jj int) bool { return h2[ii].CreatedAt.Before(*h2[jj].CreatedAt.Time) }) 116 | assert.EqualValues(t, []*BalanceHistory{}, h2) 117 | } 118 | -------------------------------------------------------------------------------- /cmd/freezer-coin-distributer/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | FROM golang:latest AS build 4 | ARG SERVICE_NAME 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | 8 | WORKDIR /app/ 9 | COPY . /app/ 10 | 11 | ENV CGO_ENABLED=0 12 | ENV GOOS=$TARGETOS 13 | ENV GOARCH=$TARGETARCH 14 | 15 | RUN env SERVICE_NAME=$SERVICE_NAME make dockerfile 16 | RUN cp cmd/$SERVICE_NAME/bin bin 17 | 18 | FROM gcr.io/distroless/base-debian11:latest 19 | ARG TARGETOS 20 | ARG TARGETARCH 21 | ARG PORT=443 22 | LABEL os=$TARGETOS 23 | LABEL arch=$TARGETARCH 24 | COPY --from=build /app/bin app 25 | #You might need to expose more ports. Just add more separated by space 26 | #I.E. EXPOSE 8080 8081 8082 8083 27 | EXPOSE $PORT 28 | ENTRYPOINT ["/app"] 29 | -------------------------------------------------------------------------------- /cmd/freezer-coin-distributer/freezer_coin_distributer.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/pkg/errors" 10 | 11 | coindistribution "github.com/ice-blockchain/freezer/coin-distribution" 12 | appCfg "github.com/ice-blockchain/wintr/config" 13 | "github.com/ice-blockchain/wintr/log" 14 | "github.com/ice-blockchain/wintr/server" 15 | ) 16 | 17 | func main() { 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | defer cancel() 20 | 21 | const pkgName = "cmd/freezer-coin-distributer" 22 | 23 | var cfg struct{ Version string } 24 | appCfg.MustLoadFromKey(pkgName, &cfg) 25 | 26 | log.Info(fmt.Sprintf("starting version `%v`...", cfg.Version)) 27 | 28 | server.New(new(service), pkgName, "").ListenAndServe(ctx, cancel) 29 | } 30 | 31 | type ( 32 | // | service implements server.State and is responsible for managing the state and lifecycle of the package. 33 | service struct{ coinDistributer coindistribution.Client } 34 | ) 35 | 36 | func (s *service) RegisterRoutes(_ *server.Router) {} 37 | 38 | func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { 39 | s.coinDistributer = coindistribution.MustStartCoinDistribution(ctx, cancel) 40 | } 41 | 42 | func (s *service) Close(_ context.Context) error { 43 | return errors.Wrap(s.coinDistributer.Close(), "could not close service") 44 | } 45 | 46 | func (s *service) CheckHealth(ctx context.Context) error { 47 | log.Debug("checking health...", "package", "coin-distribution") 48 | 49 | return errors.Wrap(s.coinDistributer.CheckHealth(ctx), "failed to check coin distributer's health") 50 | } 51 | -------------------------------------------------------------------------------- /cmd/freezer-miner/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | FROM golang:latest AS build 4 | ARG SERVICE_NAME 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | 8 | WORKDIR /app/ 9 | COPY . /app/ 10 | 11 | ENV CGO_ENABLED=0 12 | ENV GOOS=$TARGETOS 13 | ENV GOARCH=$TARGETARCH 14 | 15 | RUN env SERVICE_NAME=$SERVICE_NAME make dockerfile 16 | RUN cp cmd/$SERVICE_NAME/bin bin 17 | 18 | FROM gcr.io/distroless/base-debian11:latest 19 | ARG TARGETOS 20 | ARG TARGETARCH 21 | ARG PORT=443 22 | LABEL os=$TARGETOS 23 | LABEL arch=$TARGETARCH 24 | COPY --from=build /app/bin app 25 | #You might need to expose more ports. Just add more separated by space 26 | #I.E. EXPOSE 8080 8081 8082 8083 27 | EXPOSE $PORT 28 | ENTRYPOINT ["/app"] 29 | -------------------------------------------------------------------------------- /cmd/freezer-miner/freezer_miner.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/pkg/errors" 10 | 11 | "github.com/ice-blockchain/freezer/miner" 12 | appCfg "github.com/ice-blockchain/wintr/config" 13 | "github.com/ice-blockchain/wintr/log" 14 | "github.com/ice-blockchain/wintr/server" 15 | ) 16 | 17 | func main() { 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | defer cancel() 20 | 21 | const pkgName = "cmd/freezer-miner" 22 | 23 | var cfg struct{ Version string } 24 | appCfg.MustLoadFromKey(pkgName, &cfg) 25 | 26 | log.Info(fmt.Sprintf("starting version `%v`...", cfg.Version)) 27 | 28 | server.New(new(service), pkgName, "").ListenAndServe(ctx, cancel) 29 | } 30 | 31 | type ( 32 | // | service implements server.State and is responsible for managing the state and lifecycle of the package. 33 | service struct{ miner miner.Client } 34 | ) 35 | 36 | func (s *service) RegisterRoutes(_ *server.Router) {} 37 | 38 | func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { 39 | s.miner = miner.MustStartMining(ctx, cancel) 40 | } 41 | 42 | func (s *service) Close(_ context.Context) error { 43 | return errors.Wrap(s.miner.Close(), "could not close service") 44 | } 45 | 46 | func (s *service) CheckHealth(ctx context.Context) error { 47 | log.Debug("checking health...", "package", "miner") 48 | 49 | return errors.Wrap(s.miner.CheckHealth(ctx), "failed to check miner's health") 50 | } 51 | -------------------------------------------------------------------------------- /cmd/freezer-refrigerant/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: false 4 | logger: 5 | encoder: console 6 | level: info 7 | cmd/freezer-refrigerant: 8 | host: localhost 9 | version: latest 10 | defaultEndpointTimeout: 5s 11 | httpServer: 12 | port: 34443 13 | certPath: .testdata/localhost.crt 14 | keyPath: .testdata/localhost.key 15 | defaultPagination: 16 | limit: 20 17 | maxLimit: 1000 18 | #TODO -------------------------------------------------------------------------------- /cmd/freezer-refrigerant/.testdata/expected_swagger.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ice-blockchain/freezer/0d5db8a59d5c9ef06829b009bbe40b8a5d1739e3/cmd/freezer-refrigerant/.testdata/expected_swagger.json -------------------------------------------------------------------------------- /cmd/freezer-refrigerant/.testdata/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDijCCAnKgAwIBAgIJAMeawIdSd6+8MA0GCSqGSIb3DQEBCwUAMCcxCzAJBgNV 3 | BAYTAlVTMRgwFgYDVQQDDA9FeGFtcGxlLVJvb3QtQ0EwHhcNMjIwMTA0MjEwNDE3 4 | WhcNMjQxMDI0MjEwNDE3WjBtMQswCQYDVQQGEwJVUzESMBAGA1UECAwJWW91clN0 5 | YXRlMREwDwYDVQQHDAhZb3VyQ2l0eTEdMBsGA1UECgwURXhhbXBsZS1DZXJ0aWZp 6 | Y2F0ZXMxGDAWBgNVBAMMD2xvY2FsaG9zdC5sb2NhbDCCASIwDQYJKoZIhvcNAQEB 7 | BQADggEPADCCAQoCggEBAONuA1zntIXbNaEvt/n+/Jisib/8Bjvfm2I9ENMq0TBH 8 | OGlbZgJ9ywiKsrxBYH/O2q6Dsxy9fL5cSfcMmAS0FXPrcXQx/pVNCgNWLEXZyPDk 9 | SzSR+tlPXzuryN2/jbWtgOZc73kfxQVBqUWbLyMiXaxMxVGHgpYMg0w68Ee62d2H 10 | AnA7c0YBllvggDRSaoDRJJZTc8DDGAHm9x5583zdxpCQh/EeV+zIjd2lAGF0ioYu 11 | PV69lwyrTnY/s7WG59nRYwYR50JvbI4G+5bbpf4q2W7Q0BVLqwSdMJfAfG43N5U/ 12 | 4Q1dfyJeXavFfQaZWJtEiVOU9TBiV3QQto0tI28R6J0CAwEAAaNzMHEwQQYDVR0j 13 | BDowOKErpCkwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1D 14 | QYIJANxKhfP/dJTMMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMBQGA1UdEQQNMAuC 15 | CWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAjrUp0epptzaaTULvhrFdNJ6e 16 | 2WAeJpYCxMXjms7P+B/ldyIirDqG/WEzpN64Z1gXJhtxnw7IGTsQ8eXqLmBDk045 17 | vHhVbRhjVGADc+EVwX6OzQ+WQEGZzNDPX7DBObLC1ZV5LcfUwQXyACmlARlYgXJN 18 | GZFDkijDcvY3/Hyq9NkV6VGYPKnzxaal3v3cYO8FXQHaOLnu+SLWknT56y2vTa5/ 19 | H4CoX8nrts5Fa0NuOdoyNA1c7IdHjR/dy4g5IUZW+Sbhr1nNgkECBJvJ5QOWZ3M4 20 | 4a8NroD0ikzQDaeS4Tpk54WnJLEjDgQe5fX9RMu9F2sbr+wP+gUTmHuhLg/Ptw== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /cmd/freezer-refrigerant/.testdata/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDjbgNc57SF2zWh 3 | L7f5/vyYrIm//AY735tiPRDTKtEwRzhpW2YCfcsIirK8QWB/ztqug7McvXy+XEn3 4 | DJgEtBVz63F0Mf6VTQoDVixF2cjw5Es0kfrZT187q8jdv421rYDmXO95H8UFQalF 5 | my8jIl2sTMVRh4KWDINMOvBHutndhwJwO3NGAZZb4IA0UmqA0SSWU3PAwxgB5vce 6 | efN83caQkIfxHlfsyI3dpQBhdIqGLj1evZcMq052P7O1hufZ0WMGEedCb2yOBvuW 7 | 26X+Ktlu0NAVS6sEnTCXwHxuNzeVP+ENXX8iXl2rxX0GmVibRIlTlPUwYld0ELaN 8 | LSNvEeidAgMBAAECggEBALHtN6RPgePXA7X+5ygmXOf01C/ms9nTrnTE4YzTSqVC 9 | kteaMcxxLY6ZNAwj+aMD6gHt9wrdE+K5wQQOTkAfw0jVQgVtt4aGpvbFTA25vIL5 10 | l/yg2Gd6uT6tvo/9dJhWDSosOw2/1RuvqwZRyibqk+5ggV6vbXKGh5Hz6lezzw6H 11 | P8xazcT634Tj5YhNhd00XIcr1V+kqEHZGiJP0XzrdXzjAS5NciEdW529gv4Dp4Ni 12 | zpSroznCcP6psLXS99snDg1UdQPFu90IW51i7VOBkF+RhRIMWOywO9FeFHoQ7j0u 13 | SqACHFz8HQnR0uSZ8AwnWrRhWVoBfQ6bwDjJKi/vtQECgYEA8ZxQtliNEd2ojF0s 14 | PbU7YE9vTDEY5AXk6bRPf1rJk/RTDZZwguC4MWjTBpcqawppzur8RLRJAp3WtyP4 15 | zXh7qvgeOFIaVmGUefEfg8OkXAtvwT+ogvl9HHyY3lPWQyF+WV3dN4ILWguDYiCB 16 | myL/4EqBZjSHmqfzKS6sT9x+TYkCgYEA8Pl9uH6wDSReKqmO1kNnyF+dWfP0I7wX 17 | UpSTkRvSrYQIH2VFYH+LSN5OZaku0FHQkIbgjunAT29N8p//E2ZA3L2xNIKDV+hI 18 | M+NV52YwguUROh2mIypGlPT1f7R+tiYzz27jZgctYIF3mzTMQ1TC2TqgXzG5eA2y 19 | /Ojcmj9ncXUCgYEA4y5fOkYjR3RMAsetTMy3awTmGxdjVy0vpIx138NHHYaz/WfC 20 | nV2d9F+jZWQIb6PX/8c2s4dtyzcM6SG61cD/T7CEAeM5fpW8XbjbMDNqvV3HlEc+ 21 | NQFQodOKjir4oiDBRFidJI90CxQeUstL8srDHGwSJj8obsSTQNrxDRq/7DkCgYBR 22 | cLBpmv9a4bClkHqCtXMsyAvA6+7V6Oqk8SvSPen81IN+QNaqn1BuhxtNxljY9N2d 23 | Csh35E4nSoG4fxRQ9Rz0vXNXQMis/Aby6mEM/H9mrY4d6wlMFyyViRgzWcf9PXoD 24 | IAHgaIqQdBD9NmHWW54ilmq+4WpCRbb5PKXZx5XpRQKBgQCCMpaANqren/4aeDdz 25 | F2lkEJweRsTaS13LJKkk/fGWeXo3N/sXuBPocViSzkCNoHGx1yHrG9TyC7Cz7UXj 26 | 4Dpy7gI3cg0i7gaHgC1JfYoPzCSmvnJT62TyL/5SGwF4Xkg8efmF+sVKZqsqgiiT 27 | ATGyCMbfg4XaTw84ubV2rGxvRQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /cmd/freezer-refrigerant/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | FROM golang:latest AS build 4 | ARG SERVICE_NAME 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | 8 | WORKDIR /app/ 9 | COPY . /app/ 10 | 11 | ENV CGO_ENABLED=0 12 | ENV GOOS=$TARGETOS 13 | ENV GOARCH=$TARGETARCH 14 | 15 | RUN env SERVICE_NAME=$SERVICE_NAME make dockerfile 16 | RUN cp cmd/$SERVICE_NAME/bin bin 17 | 18 | FROM gcr.io/distroless/base-debian11:latest 19 | ARG TARGETOS 20 | ARG TARGETARCH 21 | ARG PORT=443 22 | LABEL os=$TARGETOS 23 | LABEL arch=$TARGETARCH 24 | COPY --from=build /app/bin app 25 | #You might need to expose more ports. Just add more separated by space 26 | #I.E. EXPOSE 8080 8081 8082 8083 27 | EXPOSE $PORT 28 | ENTRYPOINT ["/app"] 29 | -------------------------------------------------------------------------------- /cmd/freezer-refrigerant/coin_distribution.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | 11 | coindistribution "github.com/ice-blockchain/freezer/coin-distribution" 12 | "github.com/ice-blockchain/wintr/server" 13 | ) 14 | 15 | func (s *service) setupCoinDistributionRoutes(router *server.Router) { 16 | router. 17 | Group("/v1w"). 18 | POST("/getCoinDistributionsForReview", server.RootHandler(s.GetCoinDistributionsForReview)). 19 | POST("/reviewDistributions", server.RootHandler(s.ReviewCoinDistributions)) 20 | } 21 | 22 | // GetCoinDistributionsForReview godoc 23 | // 24 | // @Schemes 25 | // @Description Fetches data of pending coin distributions for review. 26 | // @Tags CoinDistribution 27 | // @Accept json 28 | // @Produce json 29 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 30 | // @Param x_client_type query string false "the type of the client calling this API. I.E. `web`" 31 | // @Param cursor query uint64 true "current cursor to fetch data from" default(0) 32 | // @Param limit query uint64 false "count of records in response, 5000 by default" 33 | // @Param createdAtOrderBy query string false "if u want to order by createdAt" Enums(asc,desc) 34 | // @Param iceOrderBy query string false "if u want to order by ice amount" Enums(asc,desc) 35 | // @Param usernameOrderBy query string false "if u want to order by username lexicographically" Enums(asc,desc) 36 | // @Param referredByUsernameOrderBy query string false "if u want to order by referredByUsername lexicographically" Enums(asc,desc) 37 | // @Param usernameKeyword query string false "if u want to find usernames starting with keyword" 38 | // @Param referredByUsernameKeyword query string false "if u want to find referredByUsernames starting with keyword" 39 | // @Success 200 {object} coindistribution.CoinDistributionsForReview 40 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 41 | // @Failure 403 {object} server.ErrorResponse "if not allowed" 42 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 43 | // @Failure 500 {object} server.ErrorResponse 44 | // @Failure 504 {object} server.ErrorResponse "if request times out" 45 | // @Router /v1w/getCoinDistributionsForReview [POST]. 46 | func (s *service) GetCoinDistributionsForReview( //nolint:gocritic // . 47 | ctx context.Context, 48 | req *server.Request[coindistribution.GetCoinDistributionsForReviewArg, coindistribution.CoinDistributionsForReview], 49 | ) (*server.Response[coindistribution.CoinDistributionsForReview], *server.Response[server.ErrorResponse]) { 50 | if req.AuthenticatedUser.Role != adminRole { 51 | return nil, server.Forbidden(errors.Errorf("insufficient role: %v, admin role required", req.AuthenticatedUser.Role)) 52 | } 53 | if req.Data.Limit == 0 { 54 | req.Data.Limit = defaultDistributionLimit 55 | } 56 | if req.Data.CreatedAtOrderBy != "" && !strings.EqualFold(req.Data.CreatedAtOrderBy, "desc") && !strings.EqualFold(req.Data.CreatedAtOrderBy, "asc") { 57 | return nil, server.UnprocessableEntity(errors.Errorf("`createdAtOrderBy` has to be `asc` or `desc`"), "invalid params") 58 | } 59 | if req.Data.IceOrderBy != "" && !strings.EqualFold(req.Data.IceOrderBy, "desc") && !strings.EqualFold(req.Data.IceOrderBy, "asc") { 60 | return nil, server.UnprocessableEntity(errors.Errorf("`iceOrderBy` has to be `asc` or `desc`"), "invalid params") 61 | } 62 | if req.Data.UsernameOrderBy != "" && !strings.EqualFold(req.Data.UsernameOrderBy, "desc") && !strings.EqualFold(req.Data.UsernameOrderBy, "asc") { 63 | return nil, server.UnprocessableEntity(errors.Errorf("`usernameOrderBy` has to be `asc` or `desc`"), "invalid params") 64 | } 65 | if req.Data.ReferredByUsernameOrderBy != "" && !strings.EqualFold(req.Data.ReferredByUsernameOrderBy, "desc") && !strings.EqualFold(req.Data.ReferredByUsernameOrderBy, "asc") { //nolint:lll // . 66 | return nil, server.UnprocessableEntity(errors.Errorf("`referredByUsernameOrderBy` has to be `asc` or `desc`"), "invalid params") 67 | } 68 | resp, err := s.coinDistributionRepository.GetCoinDistributionsForReview(ctx, req.Data) 69 | if err != nil { 70 | return nil, server.Unexpected(errors.Wrapf(err, "failed to GetCoinDistributionsForReview for %#v", req.Data)) 71 | } 72 | 73 | return server.OK(resp), nil 74 | } 75 | 76 | // ReviewCoinDistributions godoc 77 | // 78 | // @Schemes 79 | // @Description Reviews Coin Distributions. 80 | // @Tags CoinDistribution 81 | // @Accept json 82 | // @Produce json 83 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 84 | // @Param x_client_type query string false "the type of the client calling this API. I.E. `web`" 85 | // @Param decision query string true "the decision for the current coin distributions" Enums(approve,approve-and-process-immediately,deny) 86 | // @Success 200 "OK" 87 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 88 | // @Failure 403 {object} server.ErrorResponse "if not allowed" 89 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 90 | // @Failure 500 {object} server.ErrorResponse 91 | // @Failure 504 {object} server.ErrorResponse "if request times out" 92 | // @Router /v1w/reviewDistributions [POST]. 93 | func (s *service) ReviewCoinDistributions( //nolint:gocritic // . 94 | ctx context.Context, 95 | req *server.Request[struct { 96 | Decision string `form:"decision" required:"true" swaggerignore:"true" enums:"approve,approve-and-process-immediately,deny"` 97 | }, any], 98 | ) (*server.Response[any], *server.Response[server.ErrorResponse]) { 99 | if req.AuthenticatedUser.Role != adminRole { 100 | return nil, server.Forbidden(errors.Errorf("insufficient role: %v, admin role required", req.AuthenticatedUser.Role)) 101 | } 102 | if !strings.EqualFold(req.Data.Decision, "approve") && 103 | !strings.EqualFold(req.Data.Decision, "approve-and-process-immediately") && 104 | !strings.EqualFold(req.Data.Decision, "deny") { 105 | return nil, server.UnprocessableEntity(errors.Errorf("`decision` has to be `approve`, `approve-and-process-immediately` or `deny`"), "invalid params") 106 | } 107 | if err := s.coinDistributionRepository.ReviewCoinDistributions(ctx, req.AuthenticatedUser.UserID, req.Data.Decision); err != nil { 108 | return nil, server.Unexpected(errors.Wrapf(err, "failed to ReviewCoinDistributions for adminUserID:%v,decision:%v", req.AuthenticatedUser.UserID, req.Data.Decision)) //nolint:lll // . 109 | } 110 | 111 | return server.OK[any](), nil 112 | } 113 | -------------------------------------------------------------------------------- /cmd/freezer-refrigerant/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/pkg/errors" 7 | 8 | "github.com/ice-blockchain/eskimo/users" 9 | coindistribution "github.com/ice-blockchain/freezer/coin-distribution" 10 | "github.com/ice-blockchain/freezer/tokenomics" 11 | ) 12 | 13 | // Public API. 14 | 15 | type ( 16 | StartNewMiningSessionRequestBody struct { 17 | // Specify this if you want to resurrect the user. 18 | // `true` recovers all the lost balance, `false` deletes it forever, `null/undefined` does nothing. Default is `null/undefined`. 19 | Resurrect *bool `json:"resurrect" example:"true"` 20 | UserID string `uri:"userId" swaggerignore:"true" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 21 | XClientType string `form:"x_client_type" swaggerignore:"true" required:"false" example:"web"` 22 | Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` 23 | XAccountMetadata string `header:"X-Account-Metadata" swaggerignore:"true" required:"false" example:"some token"` 24 | // Specify this if you want to skip one or more specific KYC steps before starting a new mining session or extending an existing one. 25 | // Some KYC steps are not skippable. 26 | SkipKYCSteps []users.KYCStep `json:"skipKYCSteps" example:"0,1"` 27 | } 28 | ClaimExtraBonusRequestBody struct { 29 | UserID string `uri:"userId" swaggerignore:"true" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 30 | } 31 | StartOrUpdatePreStakingRequestBody struct { 32 | Years *uint8 `json:"years" required:"true" maximum:"5" example:"1"` 33 | Allocation *uint8 `json:"allocation" required:"true" maximum:"100" example:"100"` 34 | UserID string `uri:"userId" swaggerignore:"true" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 35 | } 36 | InitializeMiningBoostUpgradeRequestBody struct { 37 | MiningBoostLevelIndex *uint8 `json:"miningBoostLevelIndex" required:"true" example:"0"` 38 | UserID string `uri:"userId" swaggerignore:"true" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 39 | } 40 | FinalizeMiningBoostUpgradeRequestBody struct { 41 | UserID string `uri:"userId" swaggerignore:"true" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 42 | Network tokenomics.BlockchainNetworkType `json:"network" required:"true" example:"ethereum" enums:"arbitrum,bnb,ethereum"` 43 | TXHash string `json:"txHash" required:"true" example:"0xf75c78ab01ee4641be46794756f46137dea03a4980126dce4f2df933cccb34ea"` 44 | } 45 | ) 46 | 47 | // Private API. 48 | 49 | const ( 50 | applicationYamlKey = "cmd/freezer-refrigerant" 51 | swaggerRootSuffix = "/tokenomics/w" 52 | 53 | adminRole = "admin" 54 | ) 55 | 56 | // Values for server.ErrorResponse#Code. 57 | const ( 58 | userNotFoundErrorCode = "USER_NOT_FOUND" 59 | prestakingDisabled = "PRESTAKING_DISABLED" 60 | miningInProgressErrorCode = "MINING_IN_PROGRESS" 61 | raceConditionErrorCode = "RACE_CONDITION" 62 | resurrectionDecisionRequiredErrorCode = "RESURRECTION_DECISION_REQUIRED" 63 | kycStepsRequiredErrorCode = "KYC_STEPS_REQUIRED" 64 | miningDisabledErrorCode = "MINING_DISABLED" 65 | noExtraBonusAvailableErrorCode = "NO_EXTRA_BONUS_AVAILABLE" 66 | extraBonusAlreadyClaimedErrorCode = "EXTRA_BONUS_ALREADY_CLAIMED" 67 | noPendingMiningBoostUpgradeFoundErrorCode = "NO_PENDING_MINING_BOOST_UPGRADE_FOUND" 68 | invalidMiningBoostUpgradeTransactionErrorCode = "INVALID_MINING_BOOST_UPGRADE_TRANSACTION" 69 | transactionAlreadyUsed = "TRANSACTION_ALREADY_USED" 70 | 71 | defaultDistributionLimit = 5000 72 | 73 | tokeroTenant = "tokero" 74 | ) 75 | 76 | // . 77 | var ( 78 | //nolint:gochecknoglobals // Because its loaded once, at runtime. 79 | cfg config 80 | errMiningDisabled = errors.New("mining disabled") 81 | errMiningBoostDisabled = errors.New("mining boost disabled") 82 | ) 83 | 84 | type ( 85 | // | service implements server.State and is responsible for managing the state and lifecycle of the package. 86 | service struct { 87 | tokenomicsProcessor tokenomics.Processor 88 | coinDistributionRepository coindistribution.Repository 89 | } 90 | config struct { 91 | Host string `yaml:"host"` 92 | Version string `yaml:"version"` 93 | Tenant string `yaml:"tenant"` 94 | } 95 | ) 96 | -------------------------------------------------------------------------------- /cmd/freezer-refrigerant/freezer_refrigerant.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "strconv" 8 | 9 | "github.com/hashicorp/go-multierror" 10 | "github.com/pkg/errors" 11 | 12 | "github.com/ice-blockchain/freezer/cmd/freezer-refrigerant/api" 13 | coindistribution "github.com/ice-blockchain/freezer/coin-distribution" 14 | "github.com/ice-blockchain/freezer/tokenomics" 15 | appCfg "github.com/ice-blockchain/wintr/config" 16 | "github.com/ice-blockchain/wintr/log" 17 | "github.com/ice-blockchain/wintr/server" 18 | ) 19 | 20 | // @title Tokenomics API 21 | // @version latest 22 | // @description API that handles everything related to user's tokenomics. 23 | // @query.collection.format multi 24 | // @schemes https 25 | // @contact.name ice.io 26 | // @contact.url https://ice.io 27 | func main() { 28 | ctx, cancel := context.WithCancel(context.Background()) 29 | defer cancel() 30 | 31 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 32 | api.SwaggerInfo.Host = cfg.Host 33 | api.SwaggerInfo.Version = cfg.Version 34 | nginxPrefix := "" 35 | if cfg.Tenant != "" { 36 | nginxPrefix = "/" + cfg.Tenant 37 | api.SwaggerInfo.BasePath = nginxPrefix 38 | } 39 | server.New(new(service), applicationYamlKey, swaggerRootSuffix, nginxPrefix).ListenAndServe(ctx, cancel) 40 | } 41 | 42 | func (s *service) RegisterRoutes(router *server.Router) { 43 | s.registerReadRoutes(router) 44 | s.setupTokenomicsRoutes(router) 45 | s.setupCoinDistributionRoutes(router) 46 | } 47 | 48 | func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { 49 | s.tokenomicsProcessor = tokenomics.StartProcessor(ctx, cancel) 50 | s.coinDistributionRepository = coindistribution.NewRepository(ctx, cancel) 51 | } 52 | 53 | func (s *service) Close(ctx context.Context) error { 54 | if ctx.Err() != nil { 55 | return errors.Wrap(ctx.Err(), "could not close processor because context ended") 56 | } 57 | 58 | return multierror.Append( 59 | errors.Wrapf(s.tokenomicsProcessor.Close(), "could not close processor"), 60 | errors.Wrapf(s.coinDistributionRepository.Close(), "could not close coindistribution repository"), 61 | ).ErrorOrNil() //nolint:wrapcheck // . 62 | } 63 | 64 | func (s *service) CheckHealth(ctx context.Context) error { 65 | log.Debug("checking health...", "package", "tokenomics") 66 | 67 | return multierror.Append( 68 | errors.Wrap(s.tokenomicsProcessor.CheckHealth(ctx), "failed to check processor's health"), 69 | errors.Wrap(s.coinDistributionRepository.CheckHealth(ctx), "failed to check coindistribution repository health"), 70 | ).ErrorOrNil() //nolint:wrapcheck // . 71 | 72 | } 73 | 74 | func contextWithHashCode[REQ, RESP any](ctx context.Context, req *server.Request[REQ, RESP]) context.Context { 75 | switch hashCode := req.AuthenticatedUser.Claims["hashCode"].(type) { 76 | case int: 77 | return tokenomics.ContextWithHashCode(ctx, uint64(hashCode)) 78 | case int64: 79 | return tokenomics.ContextWithHashCode(ctx, uint64(hashCode)) 80 | case uint64: 81 | return tokenomics.ContextWithHashCode(ctx, hashCode) 82 | case float64: 83 | return tokenomics.ContextWithHashCode(ctx, uint64(hashCode)) 84 | case string: 85 | hc, err := strconv.ParseUint(hashCode, 10, 64) 86 | log.Error(err) 87 | 88 | return tokenomics.ContextWithHashCode(ctx, hc) 89 | default: 90 | return ctx 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /cmd/freezer/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: false 4 | logger: 5 | encoder: console 6 | level: info 7 | cmd/freezer: 8 | host: localhost 9 | version: latest 10 | defaultEndpointTimeout: 5s 11 | httpServer: 12 | port: 44443 13 | certPath: .testdata/localhost.crt 14 | keyPath: .testdata/localhost.key 15 | defaultPagination: 16 | limit: 20 17 | maxLimit: 1000 18 | #TODO -------------------------------------------------------------------------------- /cmd/freezer/.testdata/expected_swagger.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ice-blockchain/freezer/0d5db8a59d5c9ef06829b009bbe40b8a5d1739e3/cmd/freezer/.testdata/expected_swagger.json -------------------------------------------------------------------------------- /cmd/freezer/.testdata/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDijCCAnKgAwIBAgIJAMeawIdSd6+8MA0GCSqGSIb3DQEBCwUAMCcxCzAJBgNV 3 | BAYTAlVTMRgwFgYDVQQDDA9FeGFtcGxlLVJvb3QtQ0EwHhcNMjIwMTA0MjEwNDE3 4 | WhcNMjQxMDI0MjEwNDE3WjBtMQswCQYDVQQGEwJVUzESMBAGA1UECAwJWW91clN0 5 | YXRlMREwDwYDVQQHDAhZb3VyQ2l0eTEdMBsGA1UECgwURXhhbXBsZS1DZXJ0aWZp 6 | Y2F0ZXMxGDAWBgNVBAMMD2xvY2FsaG9zdC5sb2NhbDCCASIwDQYJKoZIhvcNAQEB 7 | BQADggEPADCCAQoCggEBAONuA1zntIXbNaEvt/n+/Jisib/8Bjvfm2I9ENMq0TBH 8 | OGlbZgJ9ywiKsrxBYH/O2q6Dsxy9fL5cSfcMmAS0FXPrcXQx/pVNCgNWLEXZyPDk 9 | SzSR+tlPXzuryN2/jbWtgOZc73kfxQVBqUWbLyMiXaxMxVGHgpYMg0w68Ee62d2H 10 | AnA7c0YBllvggDRSaoDRJJZTc8DDGAHm9x5583zdxpCQh/EeV+zIjd2lAGF0ioYu 11 | PV69lwyrTnY/s7WG59nRYwYR50JvbI4G+5bbpf4q2W7Q0BVLqwSdMJfAfG43N5U/ 12 | 4Q1dfyJeXavFfQaZWJtEiVOU9TBiV3QQto0tI28R6J0CAwEAAaNzMHEwQQYDVR0j 13 | BDowOKErpCkwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1D 14 | QYIJANxKhfP/dJTMMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMBQGA1UdEQQNMAuC 15 | CWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAjrUp0epptzaaTULvhrFdNJ6e 16 | 2WAeJpYCxMXjms7P+B/ldyIirDqG/WEzpN64Z1gXJhtxnw7IGTsQ8eXqLmBDk045 17 | vHhVbRhjVGADc+EVwX6OzQ+WQEGZzNDPX7DBObLC1ZV5LcfUwQXyACmlARlYgXJN 18 | GZFDkijDcvY3/Hyq9NkV6VGYPKnzxaal3v3cYO8FXQHaOLnu+SLWknT56y2vTa5/ 19 | H4CoX8nrts5Fa0NuOdoyNA1c7IdHjR/dy4g5IUZW+Sbhr1nNgkECBJvJ5QOWZ3M4 20 | 4a8NroD0ikzQDaeS4Tpk54WnJLEjDgQe5fX9RMu9F2sbr+wP+gUTmHuhLg/Ptw== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /cmd/freezer/.testdata/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDjbgNc57SF2zWh 3 | L7f5/vyYrIm//AY735tiPRDTKtEwRzhpW2YCfcsIirK8QWB/ztqug7McvXy+XEn3 4 | DJgEtBVz63F0Mf6VTQoDVixF2cjw5Es0kfrZT187q8jdv421rYDmXO95H8UFQalF 5 | my8jIl2sTMVRh4KWDINMOvBHutndhwJwO3NGAZZb4IA0UmqA0SSWU3PAwxgB5vce 6 | efN83caQkIfxHlfsyI3dpQBhdIqGLj1evZcMq052P7O1hufZ0WMGEedCb2yOBvuW 7 | 26X+Ktlu0NAVS6sEnTCXwHxuNzeVP+ENXX8iXl2rxX0GmVibRIlTlPUwYld0ELaN 8 | LSNvEeidAgMBAAECggEBALHtN6RPgePXA7X+5ygmXOf01C/ms9nTrnTE4YzTSqVC 9 | kteaMcxxLY6ZNAwj+aMD6gHt9wrdE+K5wQQOTkAfw0jVQgVtt4aGpvbFTA25vIL5 10 | l/yg2Gd6uT6tvo/9dJhWDSosOw2/1RuvqwZRyibqk+5ggV6vbXKGh5Hz6lezzw6H 11 | P8xazcT634Tj5YhNhd00XIcr1V+kqEHZGiJP0XzrdXzjAS5NciEdW529gv4Dp4Ni 12 | zpSroznCcP6psLXS99snDg1UdQPFu90IW51i7VOBkF+RhRIMWOywO9FeFHoQ7j0u 13 | SqACHFz8HQnR0uSZ8AwnWrRhWVoBfQ6bwDjJKi/vtQECgYEA8ZxQtliNEd2ojF0s 14 | PbU7YE9vTDEY5AXk6bRPf1rJk/RTDZZwguC4MWjTBpcqawppzur8RLRJAp3WtyP4 15 | zXh7qvgeOFIaVmGUefEfg8OkXAtvwT+ogvl9HHyY3lPWQyF+WV3dN4ILWguDYiCB 16 | myL/4EqBZjSHmqfzKS6sT9x+TYkCgYEA8Pl9uH6wDSReKqmO1kNnyF+dWfP0I7wX 17 | UpSTkRvSrYQIH2VFYH+LSN5OZaku0FHQkIbgjunAT29N8p//E2ZA3L2xNIKDV+hI 18 | M+NV52YwguUROh2mIypGlPT1f7R+tiYzz27jZgctYIF3mzTMQ1TC2TqgXzG5eA2y 19 | /Ojcmj9ncXUCgYEA4y5fOkYjR3RMAsetTMy3awTmGxdjVy0vpIx138NHHYaz/WfC 20 | nV2d9F+jZWQIb6PX/8c2s4dtyzcM6SG61cD/T7CEAeM5fpW8XbjbMDNqvV3HlEc+ 21 | NQFQodOKjir4oiDBRFidJI90CxQeUstL8srDHGwSJj8obsSTQNrxDRq/7DkCgYBR 22 | cLBpmv9a4bClkHqCtXMsyAvA6+7V6Oqk8SvSPen81IN+QNaqn1BuhxtNxljY9N2d 23 | Csh35E4nSoG4fxRQ9Rz0vXNXQMis/Aby6mEM/H9mrY4d6wlMFyyViRgzWcf9PXoD 24 | IAHgaIqQdBD9NmHWW54ilmq+4WpCRbb5PKXZx5XpRQKBgQCCMpaANqren/4aeDdz 25 | F2lkEJweRsTaS13LJKkk/fGWeXo3N/sXuBPocViSzkCNoHGx1yHrG9TyC7Cz7UXj 26 | 4Dpy7gI3cg0i7gaHgC1JfYoPzCSmvnJT62TyL/5SGwF4Xkg8efmF+sVKZqsqgiiT 27 | ATGyCMbfg4XaTw84ubV2rGxvRQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /cmd/freezer/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | FROM golang:latest AS build 4 | ARG SERVICE_NAME 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | 8 | WORKDIR /app/ 9 | COPY . /app/ 10 | 11 | ENV CGO_ENABLED=0 12 | ENV GOOS=$TARGETOS 13 | ENV GOARCH=$TARGETARCH 14 | 15 | RUN env SERVICE_NAME=$SERVICE_NAME make dockerfile 16 | RUN cp cmd/$SERVICE_NAME/bin bin 17 | 18 | FROM gcr.io/distroless/base-debian11:latest 19 | ARG TARGETOS 20 | ARG TARGETARCH 21 | ARG PORT=443 22 | LABEL os=$TARGETOS 23 | LABEL arch=$TARGETARCH 24 | COPY --from=build /app/bin app 25 | #You might need to expose more ports. Just add more separated by space 26 | #I.E. EXPOSE 8080 8081 8082 8083 27 | EXPOSE $PORT 28 | ENTRYPOINT ["/app"] 29 | -------------------------------------------------------------------------------- /cmd/freezer/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | stdlibtime "time" 7 | 8 | "github.com/ice-blockchain/freezer/tokenomics" 9 | ) 10 | 11 | // Public API. 12 | 13 | type ( 14 | GetMiningSummaryArg struct { 15 | UserID string `uri:"userId" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 16 | } 17 | GetMiningBoostSummaryArg struct { 18 | UserID string `uri:"userId" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 19 | } 20 | GetPreStakingSummaryArg struct { 21 | UserID string `uri:"userId" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 22 | } 23 | GetBalanceSummaryArg struct { 24 | UserID string `uri:"userId" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 25 | } 26 | GetBalanceHistoryArg struct { 27 | // The start date in RFC3339 or ISO8601 formats. Default is `now` in UTC. 28 | StartDate *stdlibtime.Time `form:"startDate" swaggertype:"string" example:"2022-01-03T16:20:52.156534Z"` 29 | // The start date in RFC3339 or ISO8601 formats. Default is `end of day, relative to startDate`. 30 | EndDate *stdlibtime.Time `form:"endDate" swaggertype:"string" example:"2022-01-03T16:20:52.156534Z"` 31 | UserID string `uri:"userId" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 32 | TZ string `form:"tz" example:"-03:00"` 33 | // Default is 24. 34 | Limit uint64 `form:"limit" maximum:"1000" example:"24"` 35 | Offset uint64 `form:"offset" example:"0"` 36 | } 37 | GetRankingSummaryArg struct { 38 | UserID string `uri:"userId" allowForbiddenGet:"true" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 39 | } 40 | GetTopMinersArg struct { 41 | Keyword string `form:"keyword" example:"jdoe"` 42 | // Default is 10. 43 | Limit uint64 `form:"limit" maximum:"1000" example:"10"` 44 | Offset uint64 `form:"offset" example:"0"` 45 | } 46 | GetAdoptionArg struct{} 47 | GetTotalCoinsArg struct { 48 | TZ string `form:"tz" example:"+4:30" allowUnauthorized:"true"` 49 | Days uint64 `form:"days" example:"7"` 50 | } 51 | ) 52 | 53 | // Private API. 54 | 55 | const ( 56 | applicationYamlKey = "cmd/freezer" 57 | swaggerRoot = "/tokenomics/r" 58 | ) 59 | 60 | // Values for server.ErrorResponse#Code. 61 | const ( 62 | userNotFoundErrorCode = "USER_NOT_FOUND" 63 | userPreStakingNotEnabledErrorCode = "PRE_STAKING_NOT_ENABLED" 64 | globalRankHiddenErrorCode = "GLOBAL_RANK_HIDDEN" 65 | invalidPropertiesErrorCode = "INVALID_PROPERTIES" 66 | ) 67 | 68 | type ( 69 | // | service implements server.State and is responsible for managing the state and lifecycle of the package. 70 | service struct { 71 | tokenomicsRepository tokenomics.Repository 72 | } 73 | config struct { 74 | Host string `yaml:"host"` 75 | Version string `yaml:"version"` 76 | } 77 | ) 78 | -------------------------------------------------------------------------------- /cmd/freezer/freezer.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "strconv" 8 | 9 | "github.com/pkg/errors" 10 | 11 | "github.com/ice-blockchain/freezer/cmd/freezer/api" 12 | "github.com/ice-blockchain/freezer/tokenomics" 13 | appCfg "github.com/ice-blockchain/wintr/config" 14 | "github.com/ice-blockchain/wintr/log" 15 | "github.com/ice-blockchain/wintr/server" 16 | ) 17 | 18 | // @title Tokenomics API 19 | // @version latest 20 | // @description API that handles everything related to read-only operations for user's tokenomics and statistics about it. 21 | // @query.collection.format multi 22 | // @schemes https 23 | // @contact.name ice.io 24 | // @contact.url https://ice.io 25 | // @BasePath /v1r 26 | func main() { 27 | ctx, cancel := context.WithCancel(context.Background()) 28 | defer cancel() 29 | 30 | var cfg config 31 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 32 | api.SwaggerInfo.Host = cfg.Host 33 | api.SwaggerInfo.Version = cfg.Version 34 | server.New(new(service), applicationYamlKey, swaggerRoot).ListenAndServe(ctx, cancel) 35 | } 36 | 37 | func (s *service) RegisterRoutes(router *server.Router) { 38 | s.setupTokenomicsRoutes(router) 39 | s.setupStatisticsRoutes(router) 40 | } 41 | 42 | func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { 43 | s.tokenomicsRepository = tokenomics.New(ctx, cancel) 44 | } 45 | 46 | func (s *service) Close(ctx context.Context) error { 47 | if ctx.Err() != nil { 48 | return errors.Wrap(ctx.Err(), "could not close repository because context ended") 49 | } 50 | 51 | return errors.Wrap(s.tokenomicsRepository.Close(), "could not close repository") 52 | } 53 | 54 | func (s *service) CheckHealth(ctx context.Context) error { 55 | log.Debug("checking health...", "package", "tokenomics") 56 | 57 | return errors.Wrap(s.tokenomicsRepository.CheckHealth(ctx), "check health failed") 58 | } 59 | 60 | func contextWithHashCode[REQ, RESP any](ctx context.Context, req *server.Request[REQ, RESP]) context.Context { 61 | switch hashCode := req.AuthenticatedUser.Claims["hashCode"].(type) { 62 | case int: 63 | return tokenomics.ContextWithHashCode(ctx, uint64(hashCode)) 64 | case int64: 65 | return tokenomics.ContextWithHashCode(ctx, uint64(hashCode)) 66 | case uint64: 67 | return tokenomics.ContextWithHashCode(ctx, hashCode) 68 | case float64: 69 | return tokenomics.ContextWithHashCode(ctx, uint64(hashCode)) 70 | case string: 71 | hc, err := strconv.ParseUint(hashCode, 10, 64) 72 | log.Error(err) 73 | 74 | return tokenomics.ContextWithHashCode(ctx, hc) 75 | default: 76 | return ctx 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /cmd/freezer/statistics.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | stdlibtime "time" 11 | 12 | "github.com/pkg/errors" 13 | 14 | "github.com/ice-blockchain/freezer/tokenomics" 15 | "github.com/ice-blockchain/wintr/server" 16 | ) 17 | 18 | func (s *service) setupStatisticsRoutes(router *server.Router) { 19 | router. 20 | Group("/v1r"). 21 | GET("/tokenomics-statistics/top-miners", server.RootHandler(s.GetTopMiners)). 22 | GET("/tokenomics-statistics/adoption", server.RootHandler(s.GetAdoption)). 23 | GET("/tokenomics-statistics/total-coins", server.RootHandler(s.GetTotalCoins)) 24 | } 25 | 26 | // GetTopMiners godoc 27 | // 28 | // @Schemes 29 | // @Description Returns the paginated leaderboard with top miners. 30 | // @Tags Statistics 31 | // @Accept json 32 | // @Produce json 33 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 34 | // @Param keyword query string false "a keyword to look for in the user's username or firstname/lastname" 35 | // @Param limit query uint64 false "max number of elements to return. Default is `10`." 36 | // @Param offset query uint64 false "number of elements to skip before starting to fetch data" 37 | // @Success 200 {array} tokenomics.Miner 38 | // @Failure 400 {object} server.ErrorResponse "if validations fail" 39 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 40 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 41 | // @Failure 500 {object} server.ErrorResponse 42 | // @Failure 504 {object} server.ErrorResponse "if request times out" 43 | // @Header 200 {integer} X-Next-Offset "if this value is 0, pagination stops, if not, use it in the `offset` query param for the next call. " 44 | // @Router /tokenomics-statistics/top-miners [GET]. 45 | func (s *service) GetTopMiners( //nolint:gocritic // False negative. 46 | ctx context.Context, 47 | req *server.Request[GetTopMinersArg, []*tokenomics.Miner], 48 | ) (*server.Response[[]*tokenomics.Miner], *server.Response[server.ErrorResponse]) { 49 | const defaultLimit, maxLimit = 10, 1000 50 | if req.Data.Limit == 0 { 51 | req.Data.Limit = defaultLimit 52 | } 53 | if req.Data.Limit > maxLimit { 54 | req.Data.Limit = maxLimit 55 | } 56 | resp, nextOffset, err := s.tokenomicsRepository.GetTopMiners(ctx, req.Data.Keyword, req.Data.Limit, req.Data.Offset) 57 | if err != nil { 58 | return nil, server.Unexpected(errors.Wrapf(err, "failed to get top miners for userID:%v & req:%#v", req.AuthenticatedUser.UserID, req.Data)) 59 | } 60 | 61 | return &server.Response[[]*tokenomics.Miner]{ 62 | Code: http.StatusOK, 63 | Data: &resp, 64 | Headers: map[string]string{"X-Next-Offset": strconv.FormatUint(nextOffset, 10)}, 65 | }, nil 66 | } 67 | 68 | // GetAdoption godoc 69 | // 70 | // @Schemes 71 | // @Description Returns the current adoption information. 72 | // @Tags Statistics 73 | // @Accept json 74 | // @Produce json 75 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 76 | // @Success 200 {object} tokenomics.AdoptionSummary 77 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 78 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 79 | // @Failure 500 {object} server.ErrorResponse 80 | // @Failure 504 {object} server.ErrorResponse "if request times out" 81 | // @Router /tokenomics-statistics/adoption [GET]. 82 | func (s *service) GetAdoption( //nolint:gocritic // False negative. 83 | ctx context.Context, 84 | req *server.Request[GetAdoptionArg, tokenomics.AdoptionSummary], 85 | ) (*server.Response[tokenomics.AdoptionSummary], *server.Response[server.ErrorResponse]) { 86 | resp, err := s.tokenomicsRepository.GetAdoptionSummary(ctx, req.AuthenticatedUser.UserID) 87 | if err != nil { 88 | return nil, server.Unexpected(errors.Wrapf(err, "failed to get adoption summary for userID:%v", req.AuthenticatedUser.UserID)) 89 | } 90 | 91 | return server.OK(resp), nil 92 | } 93 | 94 | // GetTotalCoins godoc 95 | // 96 | // @Schemes 97 | // @Description Returns statistics about total coins, with an usecase breakdown. 98 | // @Tags Statistics 99 | // @Accept json 100 | // @Produce json 101 | // @Param days query uint64 false "number of days in the past to look for. Defaults to 3. Max is 90." 102 | // @Param tz query string false "Timezone in format +04:30 or -03:45" 103 | // @Success 200 {object} tokenomics.TotalCoinsSummary 104 | // @Failure 400 {object} server.ErrorResponse "if validations failed" 105 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 106 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 107 | // @Failure 500 {object} server.ErrorResponse 108 | // @Failure 504 {object} server.ErrorResponse "if request times out" 109 | // @Router /tokenomics-statistics/total-coins [GET]. 110 | func (s *service) GetTotalCoins( //nolint:gocritic // False negative. 111 | ctx context.Context, 112 | req *server.Request[GetTotalCoinsArg, tokenomics.TotalCoinsSummary], 113 | ) (*server.Response[tokenomics.TotalCoinsSummary], *server.Response[server.ErrorResponse]) { 114 | const defaultDays, maxDays = 3, 90 115 | if req.Data.Days == 0 { 116 | req.Data.Days = defaultDays 117 | } 118 | if req.Data.Days > maxDays { 119 | req.Data.Days = maxDays 120 | } 121 | if req.Data.TZ == "" { 122 | req.Data.TZ = "+00:00" 123 | } 124 | utcOffset, err := stdlibtime.ParseDuration(strings.Replace(req.Data.TZ+"m", ":", "h", 1)) 125 | if err != nil { 126 | return nil, server.UnprocessableEntity(errors.Wrapf(err, "invalid timezone:`%v`", req.Data.TZ), invalidPropertiesErrorCode) 127 | } 128 | resp, err := s.tokenomicsRepository.GetTotalCoinsSummary(ctx, req.Data.Days, utcOffset) 129 | if err != nil { 130 | return nil, server.Unexpected(errors.Wrapf(err, "failed to GetTotalCoinsSummary for userID:%v,req:%#v", req.AuthenticatedUser.UserID, req.Data)) 131 | } 132 | 133 | return server.OK(resp), nil 134 | } 135 | -------------------------------------------------------------------------------- /coin-distribution/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: debug 7 | coin-distribution: 8 | alert-slack-webhook: https://hooks.slack.com/services/dummy/dummy/dummy 9 | environment: local 10 | review-url: https://some.bogus.example.com/going/somewhere 11 | startHours: 12 12 | endHours: 17 13 | development: true 14 | workers: 10 15 | batchSize: 100 16 | wintr/connectors/storage/v2: 17 | runDDL: true 18 | primaryURL: postgresql://root:pass@localhost:5433/freezer 19 | credentials: 20 | user: root 21 | password: pass 22 | replicaURLs: 23 | - postgresql://root:pass@localhost:5433/freezer -------------------------------------------------------------------------------- /coin-distribution/batch.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "fmt" 7 | "math/big" 8 | 9 | "github.com/ethereum/go-ethereum/common" 10 | 11 | "github.com/ice-blockchain/wintr/log" 12 | ) 13 | 14 | func (r *batchRecord) Address() common.Address { 15 | return common.HexToAddress(r.EthAddress) 16 | } 17 | 18 | func (r *batchRecord) Amount() *big.Int { 19 | const base = 10 20 | 21 | value, ok := big.NewInt(0).SetString(r.Iceflakes, base) 22 | if !ok { 23 | log.Panic(fmt.Sprintf("failed to parse amount %q of user %q", r.Iceflakes, r.UserID)) 24 | } 25 | 26 | return value 27 | } 28 | 29 | func (b *batch) Prepare() ([]common.Address, []*big.Int) { 30 | users := make(map[common.Address]*big.Int, len(b.Records)) 31 | for idx := range b.Records { 32 | addr := b.Records[idx].Address() 33 | amount := b.Records[idx].Amount() 34 | if prev, ok := users[addr]; ok { 35 | amount = prev.Add(prev, amount) 36 | } 37 | users[addr] = amount 38 | } 39 | 40 | addresses := make([]common.Address, 0, len(users)) 41 | amounts := make([]*big.Int, 0, len(users)) 42 | for addr, amount := range users { 43 | addresses = append(addresses, addr) 44 | amounts = append(amounts, amount) 45 | } 46 | 47 | return addresses, amounts 48 | } 49 | 50 | func (b *batch) Users() []string { 51 | users := make([]string, len(b.Records)) //nolint:makezero //. 52 | for idx := range b.Records { 53 | users[idx] = b.Records[idx].UserID 54 | } 55 | 56 | return users 57 | } 58 | 59 | func (b *batch) SetStatus(status ethApiStatus) { 60 | for idx := range b.Records { 61 | b.Records[idx].EthStatus = status 62 | } 63 | } 64 | 65 | func (b *batch) SetAccepted(txHash string) { 66 | for idx := range b.Records { 67 | b.Records[idx].EthStatus = ethApiStatusAccepted 68 | b.Records[idx].EthTX = &txHash 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /coin-distribution/client.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "math/big" 9 | "net" 10 | "net/http" 11 | "strings" 12 | "sync" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/ethereum/go-ethereum" 17 | "github.com/ethereum/go-ethereum/accounts/abi/bind" 18 | "github.com/ethereum/go-ethereum/common" 19 | "github.com/ethereum/go-ethereum/core" 20 | "github.com/ethereum/go-ethereum/core/types" 21 | "github.com/ethereum/go-ethereum/crypto" 22 | "github.com/ethereum/go-ethereum/ethclient" 23 | "github.com/ethereum/go-ethereum/rpc" 24 | "github.com/hashicorp/go-multierror" 25 | "github.com/pkg/errors" 26 | 27 | coindistribution "github.com/ice-blockchain/freezer/coin-distribution/internal" 28 | "github.com/ice-blockchain/wintr/log" 29 | ) 30 | 31 | func mustNewEthClient(ctx context.Context, endpoint, privateKey, contract string) *ethClientImpl { 32 | key, err := crypto.HexToECDSA(privateKey) 33 | log.Panic(errors.Wrap(err, "failed to parse private key")) //nolint:revive,nolintlint //. 34 | 35 | rpcClient, err := ethclient.DialContext(ctx, endpoint) 36 | log.Panic(errors.Wrap(err, "failed to connect to ethereum RPC")) //nolint:revive,nolintlint //. 37 | 38 | distributor, err := coindistribution.NewCoindistribution(common.HexToAddress(contract), rpcClient) 39 | log.Panic(errors.Wrap(err, "failed to create contract instance")) //nolint:revive,nolintlint //. 40 | 41 | return ðClientImpl{ 42 | RPC: rpcClient, 43 | AirDropper: distributor, 44 | Key: key, 45 | Mutex: new(sync.Mutex), 46 | } 47 | } 48 | 49 | func handleRPCError(ctx context.Context, target error) (retryAfter time.Duration) { 50 | var sysErr *syscall.Errno 51 | if errors.As(target, &sysErr) { 52 | return time.Second 53 | } 54 | 55 | var opErr *net.OpError 56 | if errors.As(target, &opErr) { 57 | return time.Second * 5 58 | } 59 | 60 | var httpErr *rpc.HTTPError 61 | if errors.As(target, &httpErr) { 62 | if httpErr.StatusCode == http.StatusTooManyRequests { 63 | return time.Hour 64 | } else if httpErr.StatusCode >= http.StatusInternalServerError { 65 | return time.Minute 66 | } 67 | 68 | return 0 69 | } 70 | 71 | // We may have two types of errors here: 72 | // 1. Errors from ethereum RPC. 73 | // 2. Errors from ethereum module (pre validation). 74 | // The first type of errors are wrapped (see core.ErrXXX), the second type of errors are not wrapped. Just strings. As is. 75 | // So check the second case with HasPrefix() and the first case with errors.Is(). 76 | if errors.Is(target, core.ErrIntrinsicGas) || strings.HasPrefix(target.Error(), core.ErrIntrinsicGas.Error()) { 77 | log.Error(errors.Wrap(sendEthereumGasLimitTooLowSlackMessage(ctx, target.Error()), "failed to send slack message")) 78 | 79 | return time.Minute * 10 80 | } 81 | 82 | for _, ethErr := range []error{ 83 | core.ErrNonceTooLow, 84 | core.ErrNonceMax, 85 | core.ErrGasLimitReached, 86 | core.ErrGasUintOverflow, 87 | core.ErrInsufficientFundsForTransfer, 88 | core.ErrMaxInitCodeSizeExceeded, 89 | core.ErrInsufficientFunds, 90 | core.ErrTxTypeNotSupported, 91 | core.ErrSenderNoEOA, 92 | core.ErrBlobFeeCapTooLow, 93 | } { 94 | if errors.Is(target, ethErr) || strings.HasPrefix(target.Error(), ethErr.Error()) { 95 | return 0 96 | } 97 | } 98 | 99 | return time.Minute 100 | } 101 | 102 | func maybeRetryRPCRequest[T any](ctx context.Context, fn func() (T, error)) (val T, err error) { 103 | main: 104 | for attempt := 1; ctx.Err() == nil; attempt++ { 105 | val, err = fn() 106 | if err == nil { 107 | return val, nil 108 | } 109 | 110 | retryAfter := handleRPCError(ctx, err) 111 | if retryAfter == 0 { 112 | log.Error(errors.Wrapf(err, "failed to call ethereum RPC (attempt %v), unrecoverable error", attempt)) 113 | 114 | return val, multierror.Append(errClientUncoverable, err) 115 | } 116 | 117 | log.Error(errors.Wrapf(err, "failed to call ethereum RPC (attempt %v), retrying after %v", attempt, retryAfter.String())) 118 | retryTimer := time.NewTimer(retryAfter) 119 | select { 120 | case <-ctx.Done(): 121 | retryTimer.Stop() 122 | 123 | break main 124 | 125 | case <-retryTimer.C: 126 | retryTimer.Stop() 127 | } 128 | } 129 | 130 | return val, multierror.Append(err, ctx.Err()) 131 | } 132 | 133 | func (ec *ethClientImpl) SuggestGasPrice(ctx context.Context) (*big.Int, error) { 134 | return maybeRetryRPCRequest(ctx, func() (*big.Int, error) { 135 | return ec.RPC.SuggestGasPrice(ctx) //nolint:wrapcheck //. 136 | }) 137 | } 138 | 139 | func (ec *ethClientImpl) AirdropToWallets(opts *bind.TransactOpts, recipients []common.Address, amounts []*big.Int) (*types.Transaction, error) { 140 | // The slow zone, we **must** have `nonce` as a linear sequence, **without** gaps. 141 | ec.Mutex.Lock() 142 | defer ec.Mutex.Unlock() 143 | 144 | tx, err := ec.AirDropper.AirdropToWallets(opts, recipients, amounts) 145 | if err == nil && opts.Context.Err() == nil { 146 | log.Info(fmt.Sprintf("airdropper: new transaction: %v | nonce %v | gas %v | cost %v | limit %v | recipients %v", 147 | tx.Hash().String(), 148 | tx.Nonce(), 149 | tx.GasPrice().String(), 150 | tx.Cost().String(), 151 | tx.Gas(), 152 | len(recipients), 153 | )) 154 | } 155 | 156 | return tx, err //nolint:wrapcheck //. 157 | } 158 | 159 | func (ec *ethClientImpl) CreateTransactionOpts(ctx context.Context, gasPrice, chanID *big.Int, gasLimit uint64) *bind.TransactOpts { 160 | opts, err := bind.NewKeyedTransactorWithChainID(ec.Key, chanID) 161 | log.Panic(errors.Wrap(err, "failed to create transaction options")) //nolint:revive,nolintlint //. 162 | opts.Context = ctx 163 | opts.Value = big.NewInt(0) 164 | opts.GasLimit = gasLimit 165 | opts.GasPrice = gasPrice 166 | 167 | return opts 168 | } 169 | 170 | func (ec *ethClientImpl) Airdrop(ctx context.Context, chanID *big.Int, gas gasGetter, recipients []common.Address, amounts []*big.Int) (string, error) { 171 | fn := func() (string, error) { 172 | gasPrice, gasLimit, err := gas.GetGasOptions(ctx) 173 | if err != nil { 174 | return "", errors.Wrap(err, "failed to get gas options") 175 | } 176 | 177 | opts := ec.CreateTransactionOpts(ctx, gasPrice, chanID, gasLimit) 178 | tx, err := ec.AirdropToWallets(opts, recipients, amounts) 179 | if err != nil { 180 | return "", err 181 | } 182 | 183 | return tx.Hash().String(), nil 184 | } 185 | 186 | return maybeRetryRPCRequest(ctx, fn) 187 | } 188 | 189 | func (ec *ethClientImpl) TransactionStatus(ctx context.Context, hash string) (ethTxStatus, error) { 190 | return maybeRetryRPCRequest(ctx, func() (ethTxStatus, error) { 191 | receipt, err := ec.RPC.TransactionReceipt(ctx, common.HexToHash(hash)) 192 | if err != nil { 193 | if errors.Is(err, ethereum.NotFound) { 194 | return ethTxStatusPending, nil 195 | } 196 | 197 | return "", err //nolint:wrapcheck //. 198 | } 199 | 200 | if receipt.Status == types.ReceiptStatusSuccessful { 201 | return ethTxStatusSuccessful, nil 202 | } 203 | 204 | return ethTxStatusFailed, nil 205 | }) 206 | } 207 | 208 | func (ec *ethClientImpl) TransactionsStatus(ctx context.Context, hashes []*string) (statuses map[ethTxStatus][]string, err error) { //nolint:funlen //. 209 | elements := make([]rpc.BatchElem, len(hashes)) //nolint:makezero //. 210 | results := make([]*types.Receipt, len(hashes)) //nolint:makezero //. 211 | for elementIdx := range elements { 212 | elements[elementIdx] = rpc.BatchElem{ 213 | Method: "eth_getTransactionReceipt", 214 | Args: []any{*hashes[elementIdx]}, 215 | Result: &results[elementIdx], 216 | } 217 | } 218 | 219 | if _, batchErr := maybeRetryRPCRequest(ctx, func() (bool, error) { 220 | return true, ec.RPC.Client().BatchCallContext(ctx, elements) //nolint:wrapcheck //. 221 | }); batchErr != nil { 222 | return nil, batchErr 223 | } 224 | 225 | statuses = make(map[ethTxStatus][]string) 226 | for elementIdx := range elements { 227 | receipt := results[elementIdx] 228 | if receipt == nil { 229 | // Transaction is not mined yet. 230 | continue 231 | } else if elements[elementIdx].Error != nil { 232 | err = multierror.Append(err, elements[elementIdx].Error) 233 | 234 | continue 235 | } 236 | 237 | if receipt.Status == types.ReceiptStatusSuccessful { 238 | statuses[ethTxStatusSuccessful] = append(statuses[ethTxStatusSuccessful], *hashes[elementIdx]) 239 | } else { 240 | statuses[ethTxStatusFailed] = append(statuses[ethTxStatusFailed], *hashes[elementIdx]) 241 | } 242 | } 243 | 244 | return statuses, err //nolint:wrapcheck //. 245 | } 246 | 247 | func (ec *ethClientImpl) Close() error { 248 | ec.RPC.Close() 249 | 250 | return nil 251 | } 252 | -------------------------------------------------------------------------------- /coin-distribution/client_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "math/big" 9 | "math/rand" 10 | "net" 11 | "sync" 12 | "syscall" 13 | "testing" 14 | 15 | "github.com/ethereum/go-ethereum/accounts/abi/bind" 16 | "github.com/ethereum/go-ethereum/common" 17 | "github.com/ethereum/go-ethereum/core/types" 18 | "github.com/ethereum/go-ethereum/crypto" 19 | "github.com/stretchr/testify/require" 20 | 21 | "github.com/ice-blockchain/wintr/log" 22 | ) 23 | 24 | type ( 25 | mockedDummyEthClient struct { 26 | dropErr error 27 | txErr map[string]error 28 | gas int64 29 | } 30 | mockedAirDropper struct { 31 | errBefore int 32 | } 33 | mockedGasGetter struct { 34 | val int64 35 | } 36 | ) 37 | 38 | func (m *mockedDummyEthClient) SuggestGasPrice(context.Context) (*big.Int, error) { 39 | if m.gas == 0 { 40 | m.gas = rand.Int63n(10_000) + 1 //nolint:gosec //. 41 | } 42 | 43 | m.gas += rand.Int63n(1_000) + 1 //nolint:gosec //. 44 | 45 | return big.NewInt(m.gas), nil 46 | } 47 | 48 | func (m *mockedDummyEthClient) Airdrop(context.Context, *big.Int, gasGetter, []common.Address, []*big.Int) (string, error) { 49 | if m.dropErr != nil { 50 | return "", m.dropErr 51 | } 52 | 53 | return fmt.Sprintf("%10d", rand.Int63n(10_000_000_000)), nil //nolint:gosec //. 54 | } 55 | 56 | func (*mockedDummyEthClient) Close() error { 57 | return nil 58 | } 59 | 60 | func (*mockedDummyEthClient) TransactionsStatus(context.Context, []*string) (map[ethTxStatus][]string, error) { 61 | return nil, nil //nolint:nilnil //. 62 | } 63 | 64 | func (m *mockedDummyEthClient) TransactionStatus(_ context.Context, hash string) (ethTxStatus, error) { 65 | if err, ok := m.txErr[hash]; ok { 66 | return ethTxStatusFailed, err 67 | } 68 | 69 | return ethTxStatusSuccessful, nil 70 | } 71 | 72 | func (m *mockedAirDropper) AirdropToWallets(opts *bind.TransactOpts, _ []common.Address, _ []*big.Int) (*types.Transaction, error) { 73 | if m.errBefore > 0 { 74 | m.errBefore-- 75 | 76 | log.Info(fmt.Sprintf("airdropper: error(s) left: %v", m.errBefore)) 77 | 78 | return nil, &net.OpError{Err: syscall.ECONNRESET} 79 | } 80 | 81 | log.Info(fmt.Sprintf("airdropper: gas price %v, limit %v", opts.GasPrice.String(), opts.GasLimit)) 82 | 83 | return types.NewTransaction( 84 | 0, 85 | common.HexToAddress("095e7baea6a6c7c4c2dfeb977efac326af552d87"), 86 | big.NewInt(0), 87 | 0, 88 | big.NewInt(0), 89 | nil, 90 | ), 91 | nil 92 | } 93 | 94 | func (m *mockedGasGetter) GetGasOptions(context.Context) (*big.Int, uint64, error) { 95 | m.val++ 96 | 97 | log.Info(fmt.Sprintf("gas getter: %v", m.val)) 98 | 99 | return big.NewInt(m.val), uint64(m.val), nil 100 | } 101 | 102 | func TestGasPriceUpdateDuringRetry(t *testing.T) { 103 | t.Parallel() 104 | 105 | const errCount = 3 106 | 107 | privateKey, err := crypto.GenerateKey() 108 | require.NoError(t, err) 109 | 110 | dropper := &mockedAirDropper{errBefore: errCount} 111 | 112 | impl := new(ethClientImpl) 113 | impl.Mutex = new(sync.Mutex) 114 | impl.Key = privateKey 115 | impl.AirDropper = dropper 116 | gasGetter := new(mockedGasGetter) 117 | 118 | _, err = impl.Airdrop(context.TODO(), big.NewInt(1), gasGetter, []common.Address{{1}}, []*big.Int{big.NewInt(1)}) 119 | require.NoError(t, err) 120 | 121 | require.Zero(t, dropper.errBefore) 122 | require.Equal(t, errCount+1, int(gasGetter.val)) 123 | } 124 | -------------------------------------------------------------------------------- /coin-distribution/coin_distribution.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "context" 7 | "encoding" 8 | "fmt" 9 | stdlibtime "time" 10 | 11 | "github.com/hashicorp/go-multierror" 12 | "github.com/pkg/errors" 13 | "golang.org/x/exp/constraints" 14 | 15 | appCfg "github.com/ice-blockchain/wintr/config" 16 | "github.com/ice-blockchain/wintr/connectors/storage/v2" 17 | "github.com/ice-blockchain/wintr/log" 18 | "github.com/ice-blockchain/wintr/time" 19 | ) 20 | 21 | func (d *databaseConfig) MustDisable(reason string) { 22 | for err := d.Disable(context.Background()); err != nil; err = d.Disable(context.Background()) { 23 | log.Error(errors.Wrap(err, "failed to disable coinDistributer")) 24 | stdlibtime.Sleep(stdlibtime.Second) 25 | } 26 | 27 | ctx, cancel := context.WithTimeout(context.Background(), requestDeadline) 28 | defer cancel() 29 | log.Error(sendCoinDistributionsProcessingStoppedDueToUnrecoverableFailureSlackMessage(ctx, reason), 30 | "failed to sendCoinDistributionsProcessingStoppedDueToUnrecoverableFailureSlackMessage") 31 | } 32 | 33 | func databaseSetValue[T bool | constraints.Integer | *time.Time](ctx context.Context, db storage.Execer, key string, value T) error { 34 | var textValue string 35 | 36 | reqCtx, cancel := context.WithTimeout(ctx, requestDeadline) 37 | defer cancel() 38 | 39 | switch i := any(value).(type) { 40 | case encoding.TextMarshaler: 41 | v, err := i.MarshalText() 42 | if err != nil { 43 | return errors.Wrapf(err, "failed to marshal %v", value) 44 | } 45 | textValue = string(v) 46 | default: 47 | textValue = fmt.Sprintf("%v", value) 48 | } 49 | 50 | rows, err := storage.Exec(reqCtx, db, `UPDATE global SET value = $2 WHERE key = $1`, key, textValue) 51 | if err == nil && rows == 0 { 52 | err = storage.ErrNotFound 53 | } 54 | 55 | return errors.Wrapf(err, "failed to set %v to %q", key, textValue) 56 | } 57 | 58 | func databaseGetValue[T bool | constraints.Integer | time.Time](ctx context.Context, db storage.Querier, key string, value *T) error { 59 | var hint string 60 | 61 | if value == nil { 62 | log.Panic(key + ": value is nil") 63 | } 64 | 65 | reqCtx, cancel := context.WithTimeout(ctx, requestDeadline) 66 | defer cancel() 67 | 68 | switch x := any(value).(type) { 69 | case *bool: 70 | hint = "boolean" 71 | case *int, *int8, *int16, *int32, *int64: 72 | hint = "bigint" 73 | case *uint, *uint8, *uint16, *uint32, *uint64: 74 | hint = "bigint" 75 | case *time.Time: 76 | hint = "timestamp with time zone" 77 | default: 78 | log.Panic(fmt.Sprintf("%s: unsupported type %T: %v", key, x, *value)) 79 | } 80 | 81 | v, err := storage.ExecOne[T](reqCtx, db, "SELECT value::"+hint+" FROM global WHERE key = $1", key) 82 | if err != nil { 83 | return errors.Wrapf(err, "failed to get %v", key) 84 | } 85 | *value = *v 86 | 87 | return nil 88 | } 89 | 90 | func (d *databaseConfig) GetGasLimit(ctx context.Context) (val uint64, err error) { 91 | err = databaseGetValue(ctx, d.DB, configKeyCoinDistributerGasLimit, &val) 92 | 93 | return val, err 94 | } 95 | 96 | func (d *databaseConfig) GetGasPriceOverride(ctx context.Context) (val uint64, err error) { 97 | err = databaseGetValue(ctx, d.DB, configKeyCoinDistributerGasPrice, &val) 98 | 99 | return val, err 100 | } 101 | 102 | func (d *databaseConfig) IsEnabled(ctx context.Context) (val bool) { 103 | log.Error(errors.Wrap(databaseGetValue(ctx, d.DB, configKeyCoinDistributerEnabled, &val), "failed to databaseGetValue")) 104 | 105 | return val 106 | } 107 | 108 | func (d *databaseConfig) IsOnDemandMode(ctx context.Context) (val bool) { 109 | log.Error(databaseGetValue(ctx, d.DB, configKeyCoinDistributerOnDemand, &val), "failed to databaseGetValue") 110 | 111 | return val 112 | } 113 | 114 | func (d *databaseConfig) DisableOnDemand(ctx context.Context) error { 115 | return databaseSetValue(ctx, d.DB, configKeyCoinDistributerOnDemand, false) 116 | } 117 | 118 | func (d *databaseConfig) Disable(ctx context.Context) error { 119 | return databaseSetValue(ctx, d.DB, configKeyCoinDistributerEnabled, false) 120 | } 121 | 122 | func (d *databaseConfig) HasPendingTransactions(ctx context.Context, status ethApiStatus) bool { 123 | reqCtx, cancel := context.WithTimeout(ctx, requestDeadline) 124 | defer cancel() 125 | 126 | val, err := storage.ExecOne[bool](reqCtx, d.DB, `SELECT true FROM pending_coin_distributions where eth_status = $1 limit 1`, status) 127 | if err != nil { 128 | if errors.Is(err, storage.ErrNotFound) { 129 | err = nil 130 | } 131 | log.Error(errors.Wrap(err, "failed to check for pending transactions")) 132 | 133 | return false 134 | } 135 | 136 | return *val 137 | } 138 | 139 | func init() { 140 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 141 | } 142 | 143 | func MustStartCoinDistribution(ctx context.Context, _ context.CancelFunc) Client { 144 | cfg.EnsureValid() 145 | eth := mustNewEthClient(ctx, cfg.Ethereum.RPC, cfg.Ethereum.PrivateKey, cfg.Ethereum.ContractAddress) 146 | 147 | cd := mustCreateCoinDistributionFromConfig(ctx, &cfg, eth) 148 | cd.MustStart(ctx, nil) 149 | 150 | go cd.repository.StartPrepareCoinDistributionsForReviewMonitor(ctx) 151 | 152 | return cd 153 | } 154 | 155 | func mustCreateCoinDistributionFromConfig(ctx context.Context, conf *config, ethClient ethClient) *coinDistributer { 156 | db := storage.MustConnect(ctx, ddl, applicationYamlKey) 157 | cd := &coinDistributer{ 158 | Client: ethClient, 159 | Processor: newCoinProcessor(ethClient, db, conf), 160 | DB: db, 161 | repository: NewRepository(ctx, nil), 162 | } 163 | 164 | return cd 165 | } 166 | 167 | func (cd *coinDistributer) MustStart(ctx context.Context, notifyProcessed chan<- *batch) { 168 | cd.Processor.Start(ctx, notifyProcessed) 169 | } 170 | 171 | func (cd *coinDistributer) Close() error { 172 | return multierror.Append( //nolint:wrapcheck //. 173 | errors.Wrap(cd.Processor.Close(), "failed to close processor"), 174 | errors.Wrap(cd.Client.Close(), "failed to close eth client"), 175 | errors.Wrap(cd.DB.Close(), "failed to close db"), 176 | errors.Wrap(cd.repository.Close(), "failed to close repository"), 177 | ).ErrorOrNil() 178 | } 179 | 180 | func (cd *coinDistributer) CheckHealth(ctx context.Context) error { 181 | return errors.Wrap(cd.DB.Ping(ctx), "[health-check] failed to ping DB") 182 | } 183 | -------------------------------------------------------------------------------- /coin-distribution/coin_distribution_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | "testing" 10 | stdlibtime "time" 11 | 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/ice-blockchain/wintr/connectors/storage/v2" 15 | "github.com/ice-blockchain/wintr/time" 16 | ) 17 | 18 | func pointerToString[T any](v *T) string { 19 | if v == nil { 20 | return "" 21 | } 22 | 23 | return fmt.Sprintf("%v", *v) 24 | } 25 | 26 | func TestFullCoinDistribution(t *testing.T) { //nolint:paralleltest,funlen //. 27 | const testUserName = "testUser" 28 | 29 | maybeSkipTest(t) 30 | 31 | rpc, privateKey, contractAddr, targetAddr := 32 | os.Getenv("TEST_ETH_ENDPOINT_RPC"), 33 | os.Getenv("TEST_ETH_PRIVATE_KEY"), 34 | os.Getenv("TEST_ETH_CONTRACT_ADDRESS"), 35 | os.Getenv("ETH_TARGET_ADDRESS") 36 | if rpc == "" || privateKey == "" || contractAddr == "" || targetAddr == "" { 37 | t.Skip("skip full coin distribution test") 38 | } 39 | 40 | cl := mustNewEthClient(context.TODO(), rpc, privateKey, contractAddr) 41 | require.NotNil(t, cl) 42 | defer cl.Close() 43 | 44 | conf := new(config) 45 | conf.Ethereum.ContractAddress = contractAddr 46 | conf.Ethereum.ChainID = 97 47 | conf.Ethereum.RPC = rpc 48 | conf.Ethereum.PrivateKey = privateKey 49 | 50 | t.Run("AddPendingEntry", func(t *testing.T) { 51 | db := storage.MustConnect(context.TODO(), ddl, applicationYamlKey) 52 | defer db.Close() 53 | 54 | helperTruncatePendingTransactions(context.TODO(), t, db) 55 | 56 | const stmt = ` 57 | INSERT INTO pending_coin_distributions 58 | (created_at, day, internal_id, iceflakes, user_id, eth_address) 59 | VALUES (now(), CURRENT_DATE, 1, '10000000000000000000000000'::uint256, $1, $2) 60 | ON CONFLICT (user_id,day) DO NOTHING 61 | ` 62 | 63 | _, err := storage.Exec(context.TODO(), db, stmt, testUserName, targetAddr) 64 | require.NoError(t, err) 65 | }) 66 | 67 | cd := mustCreateCoinDistributionFromConfig(context.TODO(), conf, cl) 68 | require.NotNil(t, cd) 69 | defer cd.Close() 70 | 71 | chBatches := make(chan *batch, 1) 72 | cd.MustStart(context.TODO(), chBatches) 73 | 74 | t.Logf("waiting for batch to be processed") 75 | processedBatch := <-chBatches 76 | t.Logf("batch: %+v processed: status %v", processedBatch, processedBatch.Status) 77 | for i := range processedBatch.Records { 78 | t.Logf("record: %v processed: %v", pointerToString(processedBatch.Records[i].EthTX), processedBatch.Records[i].EthStatus) 79 | require.Equal(t, ethApiStatusAccepted, processedBatch.Records[i].EthStatus) 80 | } 81 | require.Equal(t, ethTxStatusSuccessful, processedBatch.Status) 82 | } 83 | 84 | func TestDatabaseSetGetValues(t *testing.T) { 85 | var boolValue bool 86 | 87 | maybeSkipTest(t) 88 | 89 | db := storage.MustConnect(context.TODO(), ddl, applicationYamlKey) 90 | defer db.Close() 91 | 92 | err := databaseSetValue(context.TODO(), db, configKeyCoinDistributerEnabled, false) 93 | require.NoError(t, err) 94 | 95 | err = databaseGetValue(context.TODO(), db, configKeyCoinDistributerEnabled, &boolValue) 96 | require.NoError(t, err) 97 | require.False(t, boolValue) 98 | 99 | err = databaseSetValue(context.TODO(), db, configKeyCoinDistributerEnabled, true) 100 | require.NoError(t, err) 101 | 102 | err = databaseGetValue(context.TODO(), db, configKeyCoinDistributerEnabled, &boolValue) 103 | require.NoError(t, err) 104 | require.True(t, boolValue) 105 | 106 | testTime := time.New(stdlibtime.Date(2021, 1, 2, 3, 4, 5, 0, stdlibtime.UTC)) 107 | err = databaseSetValue(context.TODO(), db, configKeyCoinDistributerMsgOnline, testTime) 108 | require.NoError(t, err) 109 | 110 | var timeValue time.Time 111 | err = databaseGetValue(context.TODO(), db, configKeyCoinDistributerMsgOnline, &timeValue) 112 | require.NoError(t, err) 113 | require.Equal(t, testTime, &timeValue) 114 | } 115 | 116 | func TestCoinDistributionWaitOK(t *testing.T) { //nolint:paralleltest,funlen //. 117 | const ( 118 | testUserName = "testUserOK" 119 | testTxOK = "0xAABBCCDDEE" 120 | ) 121 | 122 | maybeSkipTest(t) 123 | 124 | cl := &mockedDummyEthClient{} 125 | conf := new(config) 126 | 127 | t.Run("AddPendingEntry", func(t *testing.T) { 128 | db := storage.MustConnect(context.TODO(), ddl, applicationYamlKey) 129 | defer db.Close() 130 | 131 | helperTruncatePendingTransactions(context.TODO(), t, db) 132 | 133 | const stmt = ` 134 | INSERT INTO pending_coin_distributions 135 | (created_at, day, internal_id, iceflakes, user_id, eth_address, eth_status, eth_tx) 136 | VALUES (now(), CURRENT_DATE, 1, '10000000000000000000000000'::uint256, $1, $2, 'ACCEPTED', $3) 137 | ON CONFLICT (user_id,day) DO NOTHING 138 | ` 139 | 140 | _, err := storage.Exec(context.TODO(), db, stmt, testUserName, "0x1234", testTxOK) 141 | require.NoError(t, err) 142 | }) 143 | 144 | cd := mustCreateCoinDistributionFromConfig(context.TODO(), conf, cl) 145 | require.NotNil(t, cd) 146 | defer cd.Close() 147 | 148 | chBatches := make(chan *batch, 1) 149 | cd.MustStart(context.TODO(), chBatches) 150 | 151 | t.Logf("waiting for check for pending transaction") 152 | processedBatch := <-chBatches 153 | t.Logf("batch: %+v processed: status %v", processedBatch, processedBatch.Status) 154 | require.Equal(t, ethTxStatusSuccessful, processedBatch.Status) 155 | require.Equal(t, testTxOK, processedBatch.TX) 156 | require.Len(t, processedBatch.Records, 1) 157 | require.Equal(t, testTxOK, *processedBatch.Records[0].EthTX) 158 | } 159 | 160 | func TestCoinDistributionWaitFailed(t *testing.T) { //nolint:paralleltest,funlen //. 161 | const ( 162 | testUserName = "testUserOK" 163 | testTxFailed = "0xAABBCCDDEE" 164 | ) 165 | 166 | maybeSkipTest(t) 167 | 168 | cl := &mockedDummyEthClient{txErr: map[string]error{testTxFailed: nil}} 169 | conf := new(config) 170 | 171 | t.Run("AddPendingEntry", func(t *testing.T) { 172 | db := storage.MustConnect(context.TODO(), ddl, applicationYamlKey) 173 | defer db.Close() 174 | 175 | helperTruncatePendingTransactions(context.TODO(), t, db) 176 | 177 | const stmt = ` 178 | INSERT INTO pending_coin_distributions 179 | (created_at, day, internal_id, iceflakes, user_id, eth_address, eth_status, eth_tx) 180 | VALUES (now(), CURRENT_DATE, 1, '10000000000000000000000000'::uint256, $1, $2, 'ACCEPTED', $3) 181 | ON CONFLICT (user_id,day) DO NOTHING 182 | ` 183 | 184 | _, err := storage.Exec(context.TODO(), db, stmt, testUserName, "0x1234", testTxFailed) 185 | require.NoError(t, err) 186 | }) 187 | 188 | cd := mustCreateCoinDistributionFromConfig(context.TODO(), conf, cl) 189 | require.NotNil(t, cd) 190 | defer cd.Close() 191 | 192 | chBatches := make(chan *batch, 1) 193 | cd.MustStart(context.TODO(), chBatches) 194 | 195 | t.Logf("waiting for check for pending transaction") 196 | processedBatch := <-chBatches 197 | t.Logf("batch: %+v processed: status %v", processedBatch, processedBatch.Status) 198 | require.Equal(t, ethTxStatusFailed, processedBatch.Status) 199 | require.Equal(t, testTxFailed, processedBatch.TX) 200 | require.Len(t, processedBatch.Records, 1) 201 | require.Equal(t, testTxFailed, *processedBatch.Records[0].EthTX) 202 | require.False(t, cd.Processor.IsEnabled(context.TODO())) 203 | } 204 | -------------------------------------------------------------------------------- /coin-distribution/config.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "github.com/ethereum/go-ethereum/crypto" 7 | "github.com/pkg/errors" 8 | 9 | "github.com/ice-blockchain/wintr/log" 10 | ) 11 | 12 | func (cfg *config) EnsureValid() { 13 | if cfg.Ethereum.ChainID == 0 { 14 | log.Panic("ethereum.chainID must be > 0") 15 | } 16 | if cfg.Ethereum.RPC == "" { 17 | log.Panic("ethereum.rpc must not be empty") 18 | } 19 | if cfg.Ethereum.PrivateKey == "" { 20 | log.Panic("ethereum.privateKey must not be empty") 21 | } 22 | _, err := crypto.HexToECDSA(cfg.Ethereum.PrivateKey) 23 | log.Panic(errors.Wrap(err, "ethereum.privateKey is invalid")) //nolint:revive,nolintlint //. 24 | 25 | if cfg.Ethereum.ContractAddress == "" { 26 | log.Panic("ethereum.contractAddress must not be empty") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /coin-distribution/eligibility.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "strings" 7 | stdlibtime "time" 8 | 9 | "github.com/ice-blockchain/freezer/model" 10 | "github.com/ice-blockchain/wintr/time" 11 | ) 12 | 13 | const ( 14 | AllowInactiveUsers bool = true 15 | ) 16 | 17 | func IsCoinDistributionCollectorEnabled(now *time.Time, ethereumDistributionFrequencyMin stdlibtime.Duration, cs *CollectorSettings) bool { 18 | return cs.Enabled && 19 | (cs.ForcedExecution || 20 | (now.Hour() >= cs.StartHour && 21 | now.Minute() >= 20 && //nolint:gomnd // . 22 | now.After(*cs.StartDate.Time) && 23 | now.Before(cs.EndDate.Add(ethereumDistributionFrequencyMin)) && 24 | (cs.LatestDate.IsNil() || 25 | !now.Truncate(ethereumDistributionFrequencyMin).Equal(cs.LatestDate.Truncate(ethereumDistributionFrequencyMin))))) 26 | } 27 | 28 | func CalculateEthereumDistributionICEBalance( 29 | standardBalance float64, 30 | ethereumDistributionFrequencyMin, ethereumDistributionFrequencyMax stdlibtime.Duration, 31 | now, ethereumDistributionEndDate *time.Time, 32 | ) float64 { 33 | delta := ethereumDistributionEndDate.Truncate(ethereumDistributionFrequencyMin).Sub(now.Truncate(ethereumDistributionFrequencyMin)) 34 | if delta <= ethereumDistributionFrequencyMax { 35 | return standardBalance 36 | } 37 | 38 | return standardBalance / float64(int64(delta/ethereumDistributionFrequencyMax)+1) 39 | } 40 | 41 | func IsEligibleForEthereumDistribution( 42 | minMiningStreaksRequired uint64, 43 | standardBalance, minEthereumDistributionICEBalanceRequired float64, 44 | ethAddress, country string, 45 | distributionDeniedCountries map[string]struct{}, 46 | now, collectingEndedAt, miningSessionSoloStartedAt, miningSessionSoloEndedAt, ethereumDistributionEndDate *time.Time, 47 | miningSessionDuration, ethereumDistributionFrequencyMin, ethereumDistributionFrequencyMax stdlibtime.Duration) bool { 48 | var countryAllowed bool 49 | if _, countryDenied := distributionDeniedCountries[strings.ToLower(country)]; len(distributionDeniedCountries) == 0 || (country != "" && !countryDenied) { 50 | countryAllowed = true 51 | } 52 | distributedBalance := CalculateEthereumDistributionICEBalance(standardBalance, ethereumDistributionFrequencyMin, ethereumDistributionFrequencyMax, now, ethereumDistributionEndDate) //nolint:lll // . 53 | 54 | return countryAllowed && 55 | !miningSessionSoloEndedAt.IsNil() && (miningSessionSoloEndedAt.After(*collectingEndedAt.Time) || AllowInactiveUsers) && 56 | isEthereumAddressValid(ethAddress) && 57 | ((minEthereumDistributionICEBalanceRequired > 0 && distributedBalance >= minEthereumDistributionICEBalanceRequired) || (minEthereumDistributionICEBalanceRequired == 0 && distributedBalance > 0)) && //nolint:lll // . 58 | model.CalculateMiningStreak(now, miningSessionSoloStartedAt, miningSessionSoloEndedAt, miningSessionDuration) >= minMiningStreaksRequired 59 | } 60 | 61 | func IsEligibleForEthereumDistributionNow(id int64, 62 | now, lastEthereumCoinDistributionProcessedAt, coinDistributionStartDate, latestCoinDistributionCollectingDate *time.Time, 63 | ethereumDistributionFrequencyMin, ethereumDistributionFrequencyMax stdlibtime.Duration) bool { 64 | 65 | return (lastEthereumCoinDistributionProcessedAt.IsNil() && now.Truncate(ethereumDistributionFrequencyMin).Equal(coinDistributionStartDate.Truncate(ethereumDistributionFrequencyMin))) || //nolint:lll // . 66 | ((lastEthereumCoinDistributionProcessedAt.IsNil() || !lastEthereumCoinDistributionProcessedAt.Truncate(ethereumDistributionFrequencyMin).Equal(now.Truncate(ethereumDistributionFrequencyMin))) && //nolint:lll // . 67 | isEligibleForEthereumDistributionNow(id, ethereumDistributionFrequencyMin, ethereumDistributionFrequencyMax, now, coinDistributionStartDate, latestCoinDistributionCollectingDate)) //nolint:lll // . 68 | } 69 | 70 | func isEligibleForEthereumDistributionNow(id int64, 71 | ethereumDistributionFrequencyMin, ethereumDistributionFrequencyMax stdlibtime.Duration, 72 | now, coinDistributionStartDate, latestCoinDistributionCollectingDate *time.Time, 73 | ) bool { 74 | if latestCoinDistributionCollectingDate.IsNil() { 75 | return now.Truncate(ethereumDistributionFrequencyMin).Equal(coinDistributionStartDate.Truncate(ethereumDistributionFrequencyMin)) 76 | } 77 | 78 | latestCoinDistributionCollectingDay := latestCoinDistributionCollectingDate.Truncate(ethereumDistributionFrequencyMin) 79 | for now.Truncate(ethereumDistributionFrequencyMin).After(latestCoinDistributionCollectingDay) { 80 | latestCoinDistributionCollectingDay = latestCoinDistributionCollectingDay.Add(ethereumDistributionFrequencyMin) 81 | if (id % int64(ethereumDistributionFrequencyMax/ethereumDistributionFrequencyMin)) == int64((latestCoinDistributionCollectingDay.Sub(coinDistributionStartDate.Truncate(ethereumDistributionFrequencyMin).Add(ethereumDistributionFrequencyMin))%ethereumDistributionFrequencyMax)/ethereumDistributionFrequencyMin) { //nolint:lll // . 82 | return true 83 | } 84 | } 85 | 86 | return false 87 | } 88 | 89 | func isEthereumAddressValid(ethAddress string) bool { 90 | return true 91 | } 92 | -------------------------------------------------------------------------------- /coin-distribution/internal/.gitignore: -------------------------------------------------------------------------------- 1 | ICEToken.flatten.sol* 2 | output/ 3 | -------------------------------------------------------------------------------- /coin-distribution/internal/Makefile: -------------------------------------------------------------------------------- 1 | OZ_VERSION := 5.0.1 2 | OZ_URL := https://github.com/OpenZeppelin/openzeppelin-contracts/archive/refs/tags/v$(OZ_VERSION).tar.gz 3 | ICE_URL := https://codeload.github.com/ice-blockchain/erc-20-ice-coins-distribution/zip/refs/heads/master 4 | ICE_CONTRACT := output/erc-20-ice-coins-distribution-master/contracts/ICEToken.sol 5 | 6 | # Compile a contract 7 | # $(1) -- output type. One of: bin, abi. 8 | define compile_contract 9 | @set -e; \ 10 | mkdir -p output; \ 11 | if [ -e $(ICE_CONTRACT) ]; then \ 12 | echo "---> Using $(ICE_CONTRACT) for $(1) generation"; \ 13 | solc @openzeppelin=./output/openzeppelin-contracts-$(OZ_VERSION) --overwrite --$(1) $(ICE_CONTRACT) -o output/$(1); \ 14 | elif [ -e ICEToken.flatten.sol ]; then \ 15 | echo "---> Using local ICEToken.flatten.sol for $(1) generation"; \ 16 | solc --overwrite --$(1) ICEToken.flatten.sol -o output/$(1); \ 17 | else \ 18 | echo "---> No local contract was found"; \ 19 | false; \ 20 | fi 21 | endef 22 | 23 | .PHONY: all 24 | all: generate 25 | 26 | .PHONY: tools 27 | tools: 28 | @solc --version && abigen --version 29 | 30 | .PHONY: generate 31 | generate: ice_token.go 32 | 33 | .PHONY: bindata 34 | bindata: tools 35 | @$(call compile_contract,bin) 36 | 37 | .PHONY: abidata 38 | abidata: tools 39 | @$(call compile_contract,abi) 40 | 41 | ice_token.go: tools bindata abidata 42 | abigen --bin=output/bin/ICEToken.bin --abi=output/abi/ICEToken.abi --pkg=coindistribution --out=$@ 43 | 44 | download: 45 | mkdir -p output 46 | wget -O- $(OZ_URL) | tar -zxvf- -C output 47 | wget -O output/ice.zip $(ICE_URL)?token=$(TOKEN) 48 | unzip -u output/ice.zip -d output 49 | 50 | refresh: tools 51 | $(MAKE) download 52 | $(MAKE) generate 53 | 54 | clean: 55 | rm -rf output 56 | -------------------------------------------------------------------------------- /coin-distribution/processor_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "math/rand" 9 | "os" 10 | "testing" 11 | stdlibtime "time" 12 | 13 | "github.com/stretchr/testify/require" 14 | 15 | "github.com/ice-blockchain/wintr/connectors/storage/v2" 16 | "github.com/ice-blockchain/wintr/time" 17 | ) 18 | 19 | func maybeSkipTest(t *testing.T) { 20 | t.Helper() 21 | 22 | run := os.Getenv("TEST_RUN_WITH_DATABASE") 23 | if run != "yes" { 24 | t.Skip("TEST_RUN_WITH_DATABASE is not set to 'yes'") 25 | } 26 | } 27 | 28 | func RandStringBytes(n int) string { 29 | const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 30 | b := make([]byte, n) //nolint:makezero //. 31 | for i := range b { 32 | b[i] = letterBytes[rand.Intn(len(letterBytes))] //nolint:gosec //. 33 | } 34 | 35 | return string(b) 36 | } 37 | 38 | func helperAddNewPendingTransaction(ctx context.Context, t *testing.T, proc *coinProcessor, count int) { 39 | t.Helper() 40 | 41 | const stmt = ` 42 | INSERT INTO pending_coin_distributions 43 | (created_at, day, internal_id, iceflakes, user_id, eth_address) 44 | VALUES (now(), CURRENT_DATE, 1, $1, $2, $3)` 45 | 46 | t.Logf("adding %v new pending transaction(s)", count) 47 | for i := 0; i < count; i++ { 48 | _, err := storage.Exec(ctx, proc.DB, stmt, rand.Int63n(1_000_000)+1, RandStringBytes(10), RandStringBytes(16)) //nolint:gosec //. 49 | require.NoError(t, err) 50 | } 51 | } 52 | 53 | func helperTruncatePendingTransactions(ctx context.Context, t *testing.T, db *storage.DB) { 54 | t.Helper() 55 | 56 | const stmt = `TRUNCATE TABLE pending_coin_distributions` 57 | 58 | _, err := storage.Exec(ctx, db, stmt) 59 | require.NoError(t, err) 60 | 61 | _, err = storage.Exec(ctx, db, `UPDATE global SET value = 'true' WHERE key = $1`, configKeyCoinDistributerEnabled) 62 | require.NoError(t, err) 63 | 64 | _, err = storage.Exec(ctx, db, `UPDATE global SET value = 'false' WHERE key = $1`, configKeyCoinDistributerOnDemand) 65 | require.NoError(t, err) 66 | } 67 | 68 | func TestBatchPrepareFetch(t *testing.T) { //nolint:paralleltest //. 69 | maybeSkipTest(t) 70 | ctx := context.TODO() 71 | proc := newCoinProcessor(nil, storage.MustConnect(ctx, ddl, applicationYamlKey), &config{}) 72 | require.NotNil(t, proc) 73 | defer proc.Close() 74 | 75 | helperTruncatePendingTransactions(ctx, t, proc.DB) 76 | 77 | t.Run("NotEnoughData", func(t *testing.T) { //nolint:paralleltest //. 78 | _, err := proc.BatchPrepareFetch(ctx) 79 | require.ErrorIs(t, err, errNotEnoughData) 80 | }) 81 | t.Run("Fetch", func(t *testing.T) { //nolint:paralleltest //. 82 | helperAddNewPendingTransaction(ctx, t, proc, 100) 83 | b, err := proc.BatchPrepareFetch(ctx) 84 | require.NoError(t, err) 85 | require.Len(t, b.Records, 100) 86 | for _, r := range b.Records { 87 | require.Equal(t, ethApiStatusPending, r.EthStatus) 88 | } 89 | }) 90 | } 91 | 92 | func TestGetGasPrice(t *testing.T) { //nolint:tparallel //. 93 | t.Parallel() 94 | 95 | ctx := context.TODO() 96 | proc := newCoinProcessor(new(mockedDummyEthClient), nil, &config{}) 97 | require.NotNil(t, proc) 98 | defer proc.Close() 99 | 100 | gas, err := proc.GetGasPrice(ctx) 101 | require.NoError(t, err) 102 | require.NotNil(t, gas) 103 | 104 | t.Logf("gas initial: %v", gas) 105 | 106 | t.Run("FromCache", func(t *testing.T) { 107 | gasNew, cacheErr := proc.GetGasPrice(ctx) 108 | require.NoError(t, cacheErr) 109 | require.NotNil(t, gasNew) 110 | require.Equal(t, gas, gasNew) 111 | }) 112 | 113 | proc.gasPriceCache.time = time.New(stdlibtime.Now().Add(-gasPriceCacheTTL - stdlibtime.Second)) 114 | 115 | gasNew, err := proc.GetGasPrice(ctx) 116 | require.NoError(t, err) 117 | require.NotNil(t, gasNew) 118 | 119 | t.Logf("gas updated: %v", gasNew) 120 | 121 | require.NotEqual(t, gas, gasNew) 122 | } 123 | 124 | func TestProcessorDistributeAccepted(t *testing.T) { //nolint:paralleltest //. 125 | maybeSkipTest(t) 126 | ctx := context.TODO() 127 | proc := newCoinProcessor(new(mockedDummyEthClient), storage.MustConnect(ctx, ddl, applicationYamlKey), &config{}) 128 | require.NotNil(t, proc) 129 | defer proc.Close() 130 | 131 | helperTruncatePendingTransactions(ctx, t, proc.DB) 132 | helperAddNewPendingTransaction(ctx, t, proc, batchSize*3) 133 | 134 | ch := make(chan *batch, 3) 135 | proc.Start(ctx, ch) 136 | for i := 0; i < 3; i++ { 137 | data := <-ch 138 | t.Logf("batch: %v: processed with %v record(s)", data.ID, len(data.Records)) 139 | for _, r := range data.Records { 140 | require.Equal(t, ethApiStatusAccepted, r.EthStatus) 141 | } 142 | } 143 | } 144 | 145 | func TestProcessorDistributeRejected(t *testing.T) { //nolint:paralleltest //. 146 | maybeSkipTest(t) 147 | ctx := context.TODO() 148 | proc := newCoinProcessor(&mockedDummyEthClient{dropErr: errors.New("drop error")}, //nolint:goerr113 //. 149 | storage.MustConnect(ctx, ddl, applicationYamlKey), 150 | &config{}, 151 | ) 152 | require.NotNil(t, proc) 153 | defer proc.Close() 154 | 155 | helperTruncatePendingTransactions(ctx, t, proc.DB) 156 | helperAddNewPendingTransaction(ctx, t, proc, 30) 157 | 158 | ch := make(chan *batch, 3) 159 | proc.Start(ctx, ch) 160 | 161 | data := <-ch 162 | t.Logf("batch: %v: processed with %v record(s)", data.ID, len(data.Records)) 163 | for _, r := range data.Records { 164 | require.Equal(t, ethApiStatusRejected, r.EthStatus) 165 | } 166 | 167 | select { 168 | case <-ch: 169 | t.Fatal("unexpected batch") 170 | default: 171 | } 172 | } 173 | 174 | func TestIsInTimeWindow(t *testing.T) { 175 | t.Parallel() 176 | 177 | clock := func(h, m int) *time.Time { 178 | return time.New(stdlibtime.Date(2038, 1, 1, h, m, 0, 0, stdlibtime.UTC)) 179 | } 180 | 181 | require.True(t, isInTimeWindow(clock(10, 0), 10, 22)) 182 | require.True(t, isInTimeWindow(clock(23, 0), 22, 6)) 183 | require.True(t, isInTimeWindow(clock(6, 0), 22, 6)) 184 | require.True(t, isInTimeWindow(clock(0, 0), 22, 6)) 185 | require.False(t, isInTimeWindow(clock(17, 0), 22, 6)) 186 | require.False(t, isInTimeWindow(clock(23, 0), 10, 22)) 187 | require.False(t, isInTimeWindow(clock(9, 0), 10, 22)) 188 | require.True(t, isInTimeWindow(clock(2, 0), 0, 23)) 189 | require.True(t, isInTimeWindow(clock(0, 0), 0, 0)) 190 | require.True(t, isInTimeWindow(clock(1, 0), 0, 0)) 191 | 192 | require.True(t, isInTimeWindow(clock(16, 0), 16, 18)) 193 | require.True(t, isInTimeWindow(clock(16, 1), 16, 18)) 194 | require.True(t, isInTimeWindow(clock(17, 22), 16, 18)) 195 | require.True(t, isInTimeWindow(clock(18, 0), 16, 18)) 196 | require.False(t, isInTimeWindow(clock(18, 1), 16, 18)) 197 | 198 | require.Panics(t, func() { 199 | isInTimeWindow(nil, -1, 0) 200 | }) 201 | require.Panics(t, func() { 202 | isInTimeWindow(nil, 0, 24) 203 | }) 204 | } 205 | 206 | func TestProcessorTriggerOnDemand(t *testing.T) { //nolint:paralleltest //. 207 | maybeSkipTest(t) 208 | ctx := context.TODO() 209 | now := time.Now() 210 | proc := newCoinProcessor(&mockedDummyEthClient{}, 211 | storage.MustConnect(ctx, ddl, applicationYamlKey), 212 | &config{ 213 | StartHours: now.Hour() - 2, 214 | EndHours: now.Hour() - 1, 215 | }, 216 | ) 217 | require.NotNil(t, proc) 218 | defer proc.Close() 219 | 220 | helperTruncatePendingTransactions(ctx, t, proc.DB) 221 | helperAddNewPendingTransaction(ctx, t, proc, batchSize*4) 222 | 223 | ch := make(chan *batch, 4) 224 | proc.Start(ctx, ch) 225 | require.True(t, proc.isBlocked()) 226 | 227 | select { 228 | case <-ch: 229 | t.Fatal("unexpected batch outside of time window") 230 | 231 | case <-stdlibtime.After(stdlibtime.Second * 10): 232 | t.Log("firing trigger") 233 | require.NoError(t, databaseSetValue(ctx, proc.DB, configKeyCoinDistributerOnDemand, true)) 234 | } 235 | 236 | for i := 0; i < 4; i++ { 237 | select { 238 | case data := <-ch: 239 | t.Logf("batch: %v: processed with %v record(s)", data.ID, len(data.Records)) 240 | for _, r := range data.Records { 241 | require.Equal(t, ethApiStatusAccepted, r.EthStatus) 242 | } 243 | case <-stdlibtime.After(stdlibtime.Minute * 2): 244 | t.Fatalf("cannot receive batch #%v", i) 245 | } 246 | } 247 | 248 | require.False(t, proc.IsOnDemandMode(ctx)) 249 | } 250 | -------------------------------------------------------------------------------- /coin-distribution/slack_alerts.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package coindistribution 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "fmt" 9 | "net/http" 10 | stdlibtime "time" 11 | 12 | "github.com/goccy/go-json" 13 | "github.com/pkg/errors" 14 | 15 | "github.com/ice-blockchain/wintr/log" 16 | "github.com/ice-blockchain/wintr/time" 17 | ) 18 | 19 | func (r *repository) sendCurrentCoinDistributionsAvailableForReviewAreApprovedSlackMessage(ctx context.Context, recipients uint64, iceCoins float64) error { 20 | text := fmt.Sprintf(":white_check_mark:`%v` current pending coin distributions are approved and are going to be processed as soon as the coin-distributer comes online :white_check_mark:\n`users`: `%v`\n`coins`: `%v`", r.cfg.Environment, recipients, fmt.Sprintf("%.2f", iceCoins)) //nolint:lll // . 21 | 22 | return errors.Wrap(sendSlackMessage(ctx, text, r.cfg.AlertSlackWebhook), "failed to sendSlackMessage") 23 | } 24 | 25 | func (r *repository) sendCurrentCoinDistributionsAvailableForReviewAreApprovedToBeProcessedImmediatelySlackMessage(ctx context.Context, recipients uint64, iceCoins float64) error { 26 | text := fmt.Sprintf(":white_check_mark::zap:`%v` current pending coin distributions are approved and are going to be processed immediately :zap::white_check_mark:\n`users`: `%v`\n`coins`: `%v`", r.cfg.Environment, recipients, fmt.Sprintf("%.2f", iceCoins)) //nolint:lll // . 27 | 28 | return errors.Wrap(sendSlackMessage(ctx, text, r.cfg.AlertSlackWebhook), "failed to sendSlackMessage") 29 | } 30 | 31 | func (r *repository) sendCurrentCoinDistributionsAvailableForReviewAreDeniedSlackMessage(ctx context.Context) error { 32 | text := fmt.Sprintf(":no_entry:`%v` current pending coin distributions are denied and will not be processed :no_entry:", r.cfg.Environment) 33 | 34 | return errors.Wrap(sendSlackMessage(ctx, text, r.cfg.AlertSlackWebhook), "failed to sendSlackMessage") 35 | } 36 | 37 | func sendNewCoinDistributionsAvailableForReviewSlackMessage(ctx context.Context) error { 38 | text := fmt.Sprintf(":eyes:`%v` <%v|new coin distributions are available for review> :eyes:", cfg.Environment, cfg.ReviewURL) 39 | 40 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 41 | } 42 | 43 | func SendNewCoinDistributionCollectionCycleStartedSlackMessage(ctx context.Context) error { 44 | text := fmt.Sprintf(":money_mouth_face:`%v` started to collect coins for ethereum distribution :money_mouth_face:", cfg.Environment) 45 | 46 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 47 | } 48 | 49 | func SendNewCoinDistributionCollectionCycleEndedPrematurelySlackMessage(ctx context.Context) error { 50 | text := fmt.Sprintf(":recycle:`%v` collecting coins for ethereum distribution stopped prematurely :recycle:", cfg.Environment) 51 | 52 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 53 | } 54 | 55 | func sendCoinDistributerIsNowOnlineSlackMessage(ctx context.Context) error { 56 | text := fmt.Sprintf(":sun_with_face:`%v` coin distributer is now online :sun_with_face:", cfg.Environment) 57 | 58 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 59 | } 60 | 61 | func sendCoinDistributerIsNowOfflineSlackMessage(ctx context.Context) error { 62 | text := fmt.Sprintf(":sleeping:`%v` coin distributer is now offline :sleeping:", cfg.Environment) 63 | 64 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 65 | } 66 | 67 | func sendCoinDistributerHasUnfinishedWork(ctx context.Context) error { 68 | text := fmt.Sprintf(":octagonal_sign:`%v` coin distributer has unfinished work :octagonal_sign:", cfg.Environment) 69 | 70 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 71 | } 72 | 73 | func sendCoinDistributerTransactionStuck(ctx context.Context, hash string, start *time.Time) error { 74 | text := fmt.Sprintf(":octagonal_sign:`%v` transaction `%v` stuck in PENDING state since `%v` :octagonal_sign:", 75 | cfg.Environment, 76 | hash, 77 | start.Format(stdlibtime.RFC3339), 78 | ) 79 | 80 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 81 | } 82 | 83 | func sendAllCurrentCoinDistributionsWereCommittedInEthereumSlackMessage(ctx context.Context) error { 84 | text := fmt.Sprintf(":tada:`%v` all coin distributions have been committed successfully in ethereum :tada:", cfg.Environment) 85 | 86 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 87 | } 88 | 89 | func sendCoinDistributerStartedProcessingSlackMessage(ctx context.Context) error { 90 | text := fmt.Sprintf("🏁`%v` started processing pending ethereum distributions 🏁", cfg.Environment) 91 | 92 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 93 | } 94 | 95 | func sendEthereumGasLimitTooLowSlackMessage(ctx context.Context, errMsg string) error { 96 | text := fmt.Sprintf(":warning:`%v` ethereum %v. We can wait for gas prices to go down, but it could take days, or we could change the gas limit :warning:", cfg.Environment, errMsg) //nolint:lll // . 97 | 98 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 99 | } 100 | 101 | func sendCoinDistributionsProcessingStoppedDueToUnrecoverableFailureSlackMessage(ctx context.Context, reason string) error { 102 | text := fmt.Sprintf(":bangbang:`%v` coin distribution processing stopped due to failure :bangbang:\n:rotating_light: reason: `%v` :rotating_light:", cfg.Environment, reason) //nolint:lll // . 103 | 104 | return errors.Wrap(sendSlackMessage(ctx, text, cfg.AlertSlackWebhook), "failed to sendSlackMessage") 105 | } 106 | 107 | func sendSlackMessage(ctx context.Context, text, alertSlackWebhook string) error { 108 | message := struct { 109 | Text string `json:"text,omitempty"` 110 | }{ 111 | Text: text, 112 | } 113 | data, err := json.Marshal(message) 114 | if err != nil { 115 | return errors.Wrapf(err, "failed to Marshal slack message:%#v", message) 116 | } 117 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, alertSlackWebhook, bytes.NewBuffer(data)) 118 | if err != nil { 119 | return errors.Wrap(err, "newRequestWithContext failed") 120 | } 121 | 122 | log.Debug(fmt.Sprintf("sending slack message: %v", text)) 123 | 124 | const retries = 10 125 | var resp *http.Response 126 | for ix := 0; ix < retries; ix++ { 127 | if resp, err = new(http.Client).Do(req); err == nil && resp.StatusCode == http.StatusOK { 128 | break 129 | } 130 | stdlibtime.Sleep(stdlibtime.Second) 131 | } 132 | if err != nil { 133 | return errors.Wrap(err, "slack webhook request failed") 134 | } 135 | if resp.StatusCode != http.StatusOK { 136 | return errors.Errorf("unexpected statusCode:%v", resp.StatusCode) 137 | } 138 | 139 | return errors.Wrap(resp.Body.Close(), "failed to close body") 140 | } 141 | -------------------------------------------------------------------------------- /extra-bonus-notifier/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: debug 7 | extra-bonus-notifier: 8 | chunks: 1000 9 | miningSessionDuration: 24h 10 | tokenomics: 11 | extraBonuses: 12 | duration: 24h 13 | utcOffsetDuration: 1m 14 | claimWindow: 1h 15 | availabilityWindow: 10h 16 | flatValues: 17 | - 100 18 | - 100 19 | - 100 20 | - 100 21 | - 100 22 | kycPassedExtraBonus: 300 23 | -------------------------------------------------------------------------------- /extra-bonus-notifier/availability.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package extrabonusnotifier 4 | 5 | import ( 6 | "github.com/ice-blockchain/wintr/time" 7 | ) 8 | 9 | func IsExtraBonusAvailable(currentTime, extraBonusStartedAt *time.Time, id int64) (available bool) { 10 | return extraBonusStartedAt.IsNil() || (!extraBonusStartedAt.IsNil() && currentTime.After(extraBonusStartedAt.Add(cfg.ExtraBonuses.Duration))) 11 | } 12 | -------------------------------------------------------------------------------- /extra-bonus-notifier/availability_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package extrabonusnotifier 4 | 5 | import ( 6 | "testing" 7 | stdlibtime "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/ice-blockchain/wintr/time" 12 | ) 13 | 14 | var ( 15 | testTime = time.New(stdlibtime.Date(2023, 1, 2, 3, 4, 5, 6, stdlibtime.UTC)) 16 | ) 17 | 18 | func newUser() *User { 19 | u := new(User) 20 | u.UserID = "test_user_id" 21 | u.ID = 111_111 22 | 23 | return u 24 | } 25 | 26 | func Test_isExtraBonusAvailable_BonusValue(t *testing.T) { 27 | t.Parallel() 28 | 29 | t.Run("current time before extraBonusStartedAt + duration", func(t *testing.T) { 30 | now := time.New(stdlibtime.Date(testTime.Year(), testTime.Month(), testTime.Day(), 6, 00, 00, 00, testTime.Location())) 31 | 32 | m := newUser() 33 | m.UTCOffset = 180 34 | m.ExtraBonusLastClaimAvailableAt = time.New(now.Add(-stdlibtime.Hour * 24)) 35 | extraBonusStartedAt := time.Now() 36 | 37 | b := IsExtraBonusAvailable(now, extraBonusStartedAt, m.ID) 38 | require.False(t, b) 39 | require.EqualValues(t, 0, m.ExtraBonusIndex) 40 | }) 41 | 42 | t.Run("current time after extraBonusStartedAt + duration", func(t *testing.T) { 43 | now := time.New(stdlibtime.Date(testTime.Year(), testTime.Month(), testTime.Day(), 6, 00, 00, 00, testTime.Location())) 44 | 45 | m := newUser() 46 | extraBonusStartedAt := time.New(now.Add(-48 * stdlibtime.Hour)) 47 | 48 | b := IsExtraBonusAvailable(now, extraBonusStartedAt, m.ID) 49 | require.True(t, b) 50 | require.EqualValues(t, 0, m.ExtraBonusIndex) 51 | }) 52 | 53 | t.Run("extraBonusStartedAt is nil", func(t *testing.T) { 54 | now := time.New(stdlibtime.Date(testTime.Year(), testTime.Month(), testTime.Day(), 6, 00, 00, 00, testTime.Location())) 55 | 56 | m := newUser() 57 | 58 | b := IsExtraBonusAvailable(now, nil, m.ID) 59 | require.True(t, b) 60 | require.EqualValues(t, 0, m.ExtraBonusIndex) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /extra-bonus-notifier/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package extrabonusnotifier 4 | 5 | import ( 6 | stdlibtime "time" 7 | 8 | "github.com/ice-blockchain/freezer/model" 9 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 10 | "github.com/ice-blockchain/wintr/time" 11 | ) 12 | 13 | // Public API. 14 | 15 | type ( 16 | User struct { 17 | model.ExtraBonusStartedAtField 18 | model.UserIDField 19 | UpdatedUser 20 | model.UTCOffsetField 21 | } 22 | UpdatedUser struct { 23 | model.ExtraBonusLastClaimAvailableAtField 24 | model.DeserializedUsersKey 25 | model.ExtraBonusDaysClaimNotAvailableResettableField 26 | ExtraBonusIndex uint16 `redis:"-"` 27 | } 28 | ExtraBonusAvailable struct { 29 | UserID string `json:"userId,omitempty"` 30 | ExtraBonusIndex uint16 `json:"extraBonusIndex,omitempty"` 31 | } 32 | ExtraBonusConfig struct { 33 | ExtraBonuses struct { 34 | FlatValues []uint16 `yaml:"flatValues"` 35 | NewsSeenValues []uint16 `yaml:"newsSeenValues"` 36 | MiningStreakValues []uint16 `yaml:"miningStreakValues"` 37 | Duration stdlibtime.Duration `yaml:"duration"` 38 | KycPassedExtraBonus float64 `yaml:"kycPassedExtraBonus"` 39 | } `yaml:"extraBonuses"` 40 | } 41 | ) 42 | 43 | // Private API. 44 | 45 | const ( 46 | applicationYamlKey = "extra-bonus-notifier" 47 | parentApplicationYamlKey = "tokenomics" 48 | requestDeadline = 30 * stdlibtime.Second 49 | ) 50 | 51 | // . 52 | var ( 53 | //nolint:gochecknoglobals // Singleton & global config mounted only during bootstrap. 54 | cfg struct { 55 | ExtraBonusConfig `mapstructure:",squash"` //nolint:tagliatelle // Nope. 56 | messagebrokerConfig `mapstructure:",squash"` //nolint:tagliatelle // Nope. 57 | MiningSessionDuration stdlibtime.Duration `yaml:"miningSessionDuration"` 58 | Workers int64 `yaml:"workers"` 59 | BatchSize int64 `yaml:"batchSize"` 60 | Chunks uint16 `yaml:"chunks"` 61 | } 62 | ) 63 | 64 | type ( 65 | messagebrokerConfig = messagebroker.Config 66 | extraBonusNotifier struct { 67 | mb messagebroker.Client 68 | extraBonusStartDate *time.Time 69 | extraBonusIndicesDistribution map[uint16]map[uint16]uint16 70 | } 71 | ) 72 | -------------------------------------------------------------------------------- /local.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "flag" 7 | 8 | "github.com/ice-blockchain/freezer/tokenomics/fixture" 9 | "github.com/ice-blockchain/freezer/tokenomics/seeding" 10 | serverauthfixture "github.com/ice-blockchain/wintr/auth/fixture" 11 | "github.com/ice-blockchain/wintr/log" 12 | ) 13 | 14 | //nolint:gochecknoglobals // Because those are flags 15 | var ( 16 | generateAuth = flag.String("generateAuth", "", "generate a new auth for a random user, with the specified role") 17 | startSeeding = flag.Bool("startSeeding", false, "whether to start seeding a remote database or not") 18 | startLocalType = flag.String("type", "all", "the strategy to use to spin up the local environment") 19 | ) 20 | 21 | func main() { 22 | flag.Parse() 23 | if generateAuth != nil && *generateAuth != "" { 24 | userID, token := testingAuthorization(*generateAuth) 25 | log.Info("UserID") 26 | log.Info("=================================================================================") 27 | log.Info(userID) 28 | log.Info("Authorization Bearer Token") 29 | log.Info("=================================================================================") 30 | log.Info(token) 31 | 32 | return 33 | } 34 | if *startSeeding { 35 | seeding.StartSeeding() 36 | 37 | return 38 | } 39 | 40 | fixture.StartLocalTestEnvironment(fixture.StartLocalTestEnvironmentType(*startLocalType)) 41 | } 42 | 43 | func testingAuthorization(role string) (userID, token string) { 44 | return serverauthfixture.CreateUser(role) 45 | } 46 | -------------------------------------------------------------------------------- /miner/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: debug 7 | tokenomics: 8 | t1ReferralsAllowedWithoutAnyMiningBoostLevel: false 9 | welcomeBonusV2Amount: 500 10 | slashingFloor: 1 11 | adoption: 12 | milestones: 7 13 | startingBaseMiningRate: 16 14 | durationBetweenMilestones: 168h 15 | miningSessionDuration: 16 | max: 24h 17 | min: 12h 18 | extraBonuses: 19 | duration: 24h 20 | mining-boost: 21 | levels: 22 | 12.3: 23 | miningSessionLengthSeconds: 120 24 | miningRateBonus: 200 25 | maxT1Referrals: 2 26 | slashingDisabled: true 27 | 13.3: 28 | miningSessionLengthSeconds: 180 29 | miningRateBonus: 300 30 | maxT1Referrals: 2 31 | slashingDisabled: false 32 | 14.3: 33 | miningSessionLengthSeconds: 240 34 | miningRateBonus: 400 35 | maxT1Referrals: 4 36 | slashingDisabled: true 37 | 10.3: 38 | miningSessionLengthSeconds: 60 39 | miningRateBonus: 100 40 | maxT1Referrals: 1 41 | slashingDisabled: false 42 | miner: 43 | t1ReferralsAllowedWithoutAnyMiningBoostLevel: false 44 | welcomeBonusV2Amount: 500 45 | ethereumDistributionFrequency: 46 | min: 24h 47 | max: 672h 48 | slashingDaysCount: 10 -------------------------------------------------------------------------------- /miner/days_off.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package miner 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | stdlibtime "time" 9 | 10 | "github.com/goccy/go-json" 11 | "github.com/pkg/errors" 12 | 13 | "github.com/ice-blockchain/freezer/model" 14 | "github.com/ice-blockchain/freezer/tokenomics" 15 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 16 | "github.com/ice-blockchain/wintr/log" 17 | "github.com/ice-blockchain/wintr/time" 18 | ) 19 | 20 | func didANewDayOffJustStart(now *time.Time, usr *user) *DayOffStarted { 21 | if usr == nil || 22 | usr.MiningSessionSoloStartedAt.IsNil() || 23 | usr.MiningSessionSoloEndedAt.IsNil() || 24 | usr.MiningSessionSoloLastStartedAt.IsNil() || 25 | usr.BalanceLastUpdatedAt.IsNil() || 26 | usr.MiningSessionSoloEndedAt.Before(*now.Time) || 27 | usr.MiningSessionSoloLastStartedAt.Add(usr.maxMiningSessionDuration()).After(*now.Time) { 28 | return nil 29 | } 30 | naturalEndedAt := usr.MiningSessionSoloLastStartedAt.Add(usr.maxMiningSessionDuration()) 31 | startedAt := time.New(naturalEndedAt.Add((now.Sub(naturalEndedAt) / cfg.MiningSessionDuration.Max) * cfg.MiningSessionDuration.Max)) 32 | if usr.BalanceLastUpdatedAt.After(*startedAt.Time) { 33 | return nil 34 | } 35 | 36 | return &DayOffStarted{ 37 | StartedAt: startedAt, 38 | EndedAt: time.New(startedAt.Add(cfg.MiningSessionDuration.Max)), 39 | UserID: usr.UserID, 40 | ID: fmt.Sprintf("%v~%v", usr.UserID, startedAt.UnixNano()/cfg.MiningSessionDuration.Max.Nanoseconds()), 41 | RemainingFreeMiningSessions: usr.calculateRemainingFreeMiningSessions(now), 42 | MiningStreak: model.CalculateMiningStreak(now, usr.MiningSessionSoloStartedAt, usr.MiningSessionSoloEndedAt, cfg.MiningSessionDuration.Max), 43 | } 44 | } 45 | 46 | func dayOffStartedMessage(ctx context.Context, event *DayOffStarted) *messagebroker.Message { 47 | valueBytes, err := json.MarshalContext(ctx, event) 48 | log.Panic(errors.Wrapf(err, "failed to marshal %#v", event)) 49 | 50 | return &messagebroker.Message{ 51 | Headers: map[string]string{"producer": "freezer"}, 52 | Key: event.UserID, 53 | Topic: cfg.MessageBroker.Topics[5].Name, 54 | Value: valueBytes, 55 | } 56 | } 57 | 58 | func (u *user) maxMiningSessionDuration() stdlibtime.Duration { 59 | if u == nil || u.MiningBoostLevelIndex == nil { 60 | return cfg.MiningSessionDuration.Max 61 | } 62 | 63 | return stdlibtime.Duration((*cfg.miningBoostLevels.Load())[int(*u.MiningBoostLevelIndex)].MiningSessionLengthSeconds) * stdlibtime.Second 64 | } 65 | 66 | func (u *user) calculateRemainingFreeMiningSessions(now *time.Time) uint64 { 67 | if u == nil { 68 | return 0 69 | } 70 | start, end := u.MiningSessionSoloLastStartedAt, u.MiningSessionSoloEndedAt 71 | if end.IsNil() || now.After(*end.Time) { 72 | return 0 73 | } 74 | 75 | if maxMiningSession := u.maxMiningSessionDuration(); maxMiningSession > cfg.MiningSessionDuration.Max { 76 | latestMiningSession := tokenomics.CalculateMiningSession(now, start, end, maxMiningSession) 77 | 78 | if latestMiningSession == nil || end.Before(*latestMiningSession.EndedAt.Time) { 79 | return 0 80 | } 81 | 82 | return uint64(end.Sub(*latestMiningSession.EndedAt.Time) / cfg.MiningSessionDuration.Max) 83 | } 84 | 85 | return uint64(end.Sub(*now.Time) / cfg.MiningSessionDuration.Max) 86 | } 87 | -------------------------------------------------------------------------------- /miner/days_off_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package miner 4 | 5 | import ( 6 | "fmt" 7 | "regexp" 8 | "testing" 9 | stdlibtime "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/ice-blockchain/wintr/time" 14 | ) 15 | 16 | func testDidANewDayOffJustStartEmpty(t *testing.T) { 17 | t.Helper() 18 | require.Nil(t, didANewDayOffJustStart(nil, nil)) 19 | require.Nil(t, didANewDayOffJustStart(testTime, nil)) 20 | 21 | m := newUser() 22 | require.Nil(t, didANewDayOffJustStart(testTime, m)) 23 | 24 | m.BalanceLastUpdatedAt = testTime 25 | m.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 25) 26 | require.Nil(t, didANewDayOffJustStart(testTime, m)) 27 | } 28 | 29 | func testDidANewDayOffJustStartDayOff(t *testing.T) { 30 | t.Helper() 31 | m := newUser() 32 | m.BalanceLastUpdatedAt = timeDelta(-stdlibtime.Hour * 2) 33 | m.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 25) 34 | 35 | started := didANewDayOffJustStart(testTime, m) 36 | require.NotNil(t, started) 37 | require.Regexp(t, regexp.MustCompile(fmt.Sprintf("%v~[0-9]+", m.UserID)), started.ID) 38 | require.Equal(t, m.UserID, started.UserID) 39 | require.EqualValues(t, 0, started.MiningStreak) 40 | require.EqualValues(t, 0, started.RemainingFreeMiningSessions) 41 | } 42 | 43 | func testDidANewDayOffJustStartDayOff_MiningSessionSoloStartedAtIsNil(t *testing.T) { 44 | t.Helper() 45 | m := newUser() 46 | 47 | m.BalanceLastUpdatedAt = time.New(testTime.Add(-1 * stdlibtime.Hour)) 48 | m.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 49) 49 | m.MiningSessionSoloStartedAt = nil 50 | m.MiningSessionSoloEndedAt = timeDelta(stdlibtime.Hour * 49) 51 | 52 | started := didANewDayOffJustStart(testTime, m) 53 | require.Nil(t, started) 54 | } 55 | 56 | func testDidANewDayOffJustStartDayOff_MiningSessionSoloLastStartedAtIsNil(t *testing.T) { 57 | t.Helper() 58 | m := newUser() 59 | 60 | m.BalanceLastUpdatedAt = time.New(testTime.Add(-1 * stdlibtime.Hour)) 61 | m.MiningSessionSoloLastStartedAt = nil 62 | m.MiningSessionSoloStartedAt = timeDelta(-stdlibtime.Hour * 49) 63 | m.MiningSessionSoloEndedAt = timeDelta(stdlibtime.Hour * 49) 64 | 65 | started := didANewDayOffJustStart(testTime, m) 66 | require.Nil(t, started) 67 | } 68 | 69 | func testDidANewDayOffJustStartDayOff_MiningSessionSoloEndedAtIsNil(t *testing.T) { 70 | t.Helper() 71 | m := newUser() 72 | 73 | m.BalanceLastUpdatedAt = time.New(testTime.Add(-1 * stdlibtime.Hour)) 74 | m.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 49) 75 | m.MiningSessionSoloStartedAt = timeDelta(-stdlibtime.Hour * 49) 76 | m.MiningSessionSoloEndedAt = nil 77 | 78 | started := didANewDayOffJustStart(testTime, m) 79 | require.Nil(t, started) 80 | } 81 | 82 | func testDidANewDayOffJustStartDayOff_BalanceLastUpdatedAtIsNil(t *testing.T) { 83 | t.Helper() 84 | m := newUser() 85 | 86 | m.BalanceLastUpdatedAt = nil 87 | m.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 49) 88 | m.MiningSessionSoloStartedAt = timeDelta(-stdlibtime.Hour * 49) 89 | m.MiningSessionSoloEndedAt = timeDelta(stdlibtime.Hour * 49) 90 | 91 | started := didANewDayOffJustStart(testTime, m) 92 | require.Nil(t, started) 93 | } 94 | 95 | func testDidANewDayOffJustStartDayOff_MiningSessionLastStartedAfterNow(t *testing.T) { 96 | t.Helper() 97 | m := newUser() 98 | 99 | m.BalanceLastUpdatedAt = testTime 100 | m.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 23) 101 | m.MiningSessionSoloStartedAt = timeDelta(-stdlibtime.Hour * 49) 102 | m.MiningSessionSoloEndedAt = timeDelta(stdlibtime.Hour * 25) 103 | 104 | started := didANewDayOffJustStart(testTime, m) 105 | require.Nil(t, started) 106 | } 107 | 108 | func testDidANewDayOffJustStartDayOff_BalanceLastUpdatedAtAfterStartedAt(t *testing.T) { 109 | t.Helper() 110 | m := newUser() 111 | 112 | m.BalanceLastUpdatedAt = testTime 113 | m.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 49) 114 | m.MiningSessionSoloStartedAt = timeDelta(-stdlibtime.Hour * 49) 115 | m.MiningSessionSoloEndedAt = timeDelta(stdlibtime.Hour * 49) 116 | 117 | started := didANewDayOffJustStart(testTime, m) 118 | require.Nil(t, started) 119 | } 120 | 121 | func testDidANewDayOffJustStartDayOff_RemainingFreeMiningSessions(t *testing.T) { 122 | t.Helper() 123 | m := newUser() 124 | 125 | m.BalanceLastUpdatedAt = time.New(testTime.Add(-1 * stdlibtime.Hour)) 126 | m.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 49) 127 | m.MiningSessionSoloStartedAt = timeDelta(-stdlibtime.Hour * 144) 128 | m.MiningSessionSoloEndedAt = timeDelta(stdlibtime.Hour * 25) 129 | 130 | started := didANewDayOffJustStart(testTime, m) 131 | require.NotNil(t, started) 132 | require.Regexp(t, regexp.MustCompile(fmt.Sprintf("%v~[0-9]+", m.UserID)), started.ID) 133 | require.Equal(t, m.UserID, started.UserID) 134 | require.EqualValues(t, 6, started.MiningStreak) 135 | require.EqualValues(t, 1, started.RemainingFreeMiningSessions) 136 | } 137 | 138 | func testDidANewDayOffJustStartDayOff_ConcurrentCallsWithTheSameStartedAtTime(t *testing.T) { 139 | t.Helper() 140 | m1, m2 := newUser(), newUser() 141 | 142 | m1.BalanceLastUpdatedAt = time.New(testTime.Add(-1 * stdlibtime.Hour)) 143 | m1.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 49) 144 | m1.MiningSessionSoloStartedAt = timeDelta(-stdlibtime.Hour * 144) 145 | m1.MiningSessionSoloEndedAt = timeDelta(stdlibtime.Hour * 25) 146 | 147 | m2.UserID = "test_user_id2" 148 | m2.BalanceLastUpdatedAt = time.New(testTime.Add(-1 * stdlibtime.Hour)) 149 | m2.MiningSessionSoloLastStartedAt = timeDelta(-stdlibtime.Hour * 49) 150 | m2.MiningSessionSoloStartedAt = timeDelta(-stdlibtime.Hour * 144) 151 | m2.MiningSessionSoloEndedAt = timeDelta(stdlibtime.Hour * 25) 152 | 153 | started1 := didANewDayOffJustStart(testTime, m1) 154 | require.NotNil(t, started1) 155 | require.Regexp(t, regexp.MustCompile(fmt.Sprintf("%v~[0-9]+", m1.UserID)), started1.ID) 156 | require.Equal(t, m1.UserID, started1.UserID) 157 | require.EqualValues(t, 6, started1.MiningStreak) 158 | require.EqualValues(t, 1, started1.RemainingFreeMiningSessions) 159 | 160 | started2 := didANewDayOffJustStart(testTime, m2) 161 | require.NotNil(t, started2) 162 | require.Regexp(t, regexp.MustCompile(fmt.Sprintf("%v~[0-9]+", m2.UserID)), started2.ID) 163 | require.Equal(t, m2.UserID, started2.UserID) 164 | require.EqualValues(t, 6, started2.MiningStreak) 165 | require.EqualValues(t, 1, started2.RemainingFreeMiningSessions) 166 | require.NotEqual(t, started1.ID, started2.ID) 167 | } 168 | 169 | func Test_didANewDayOffJustStart(t *testing.T) { 170 | t.Parallel() 171 | 172 | t.Run("Empty", testDidANewDayOffJustStartEmpty) 173 | t.Run("DayOff", testDidANewDayOffJustStartDayOff) 174 | t.Run("MiningSessionSoloStartedAt is nil", testDidANewDayOffJustStartDayOff_MiningSessionSoloStartedAtIsNil) 175 | t.Run("MiningSessionSoloLastStartedAt is nil", testDidANewDayOffJustStartDayOff_MiningSessionSoloLastStartedAtIsNil) 176 | t.Run("BalanceLastUpdatedAt is nil", testDidANewDayOffJustStartDayOff_BalanceLastUpdatedAtIsNil) 177 | t.Run("MiningSessionSoloEndedAt is nil", testDidANewDayOffJustStartDayOff_MiningSessionSoloEndedAtIsNil) 178 | t.Run("MiningSessionLastStarted after now", testDidANewDayOffJustStartDayOff_MiningSessionLastStartedAfterNow) 179 | t.Run("BalanceLastUpdatedAt after startedAt", testDidANewDayOffJustStartDayOff_BalanceLastUpdatedAtAfterStartedAt) 180 | t.Run("RemainingFreeMiningSessions check", testDidANewDayOffJustStartDayOff_RemainingFreeMiningSessions) 181 | t.Run("Concurrent calls with the same startedAt time", testDidANewDayOffJustStartDayOff_ConcurrentCallsWithTheSameStartedAtTime) 182 | } 183 | -------------------------------------------------------------------------------- /miner/metrics.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package miner 4 | 5 | import ( 6 | "fmt" 7 | stdlog "log" 8 | "strings" 9 | stdlibtime "time" 10 | 11 | "github.com/rcrowley/go-metrics" 12 | 13 | "github.com/ice-blockchain/wintr/log" 14 | ) 15 | 16 | func init() { 17 | stdlog.SetFlags(stdlog.Ldate | stdlog.Ltime | stdlog.Lmsgprefix | stdlog.LUTC | stdlog.Lmicroseconds) 18 | } 19 | 20 | type telemetry struct { 21 | registry metrics.Registry 22 | steps [10]string 23 | currentStepName string 24 | cfg config 25 | } 26 | 27 | func (t *telemetry) mustInit(cfg config) *telemetry { 28 | const ( 29 | decayAlpha = 0.015 30 | reservoirSize = 10_000 31 | ) 32 | t.cfg = cfg 33 | t.registry = metrics.NewRegistry() 34 | t.steps = [10]string{"mine[full iteration]", "mine", "get_users", "get_referrals", "send_messages", "get_history", "sync_quiz_status", "insert_history", "collect_coin_distributions", "update_users"} //nolint:lll // . 35 | for ix := range &t.steps { 36 | if ix > 1 { 37 | t.steps[ix] = fmt.Sprintf("[%v]mine.%v", ix-1, t.steps[ix]) 38 | } 39 | log.Panic(t.registry.Register(t.steps[ix], metrics.NewCustomTimer(metrics.NewHistogram(metrics.NewExpDecaySample(reservoirSize, decayAlpha)), metrics.NewMeter()))) //nolint:lll // . 40 | } 41 | 42 | go metrics.LogScaled(t.registry, 15*stdlibtime.Minute, stdlibtime.Millisecond, t) //nolint:gomnd // . 43 | 44 | return t 45 | } 46 | 47 | func (t *telemetry) collectElapsed(step int, since stdlibtime.Time) { 48 | t.registry.Get(t.steps[step]).(metrics.Timer).UpdateSince(since) 49 | } 50 | 51 | func (t *telemetry) shouldSynchronizeBalanceFunc(workerNumber, totalBatches, iteration uint64) func(batchNumber uint64) bool { 52 | var deadline float64 53 | if t.cfg.Development { 54 | deadline = float64(stdlibtime.Minute) 55 | } else { 56 | deadline = float64(24 * stdlibtime.Hour) 57 | } 58 | timingPrevStep := t.registry.Get(t.steps[0]).(metrics.Timer).Percentile(0.99) // nolint:forcetypeassert 59 | targetIterations := uint64(deadline / timingPrevStep) 60 | targetIterations = (targetIterations / uint64(t.cfg.Workers)) * uint64(t.cfg.Workers) 61 | if targetIterations <= 0 { 62 | targetIterations = 1 63 | } 64 | iterationsOwnedBy1Worker := targetIterations / uint64(t.cfg.Workers) 65 | if iterationsOwnedBy1Worker <= 0 { 66 | iterationsOwnedBy1Worker = 1 67 | } 68 | batchesPerIterationsOwnedBy1Worker := totalBatches / iterationsOwnedBy1Worker 69 | if batchesPerIterationsOwnedBy1Worker <= 0 { 70 | batchesPerIterationsOwnedBy1Worker = 1 71 | } 72 | if totalBatches <= iterationsOwnedBy1Worker { 73 | iterationsOwnedBy1Worker = totalBatches 74 | if targetIterations >= 1 { 75 | targetIterations = iterationsOwnedBy1Worker * uint64(t.cfg.Workers) 76 | } 77 | } 78 | var ( 79 | currentIteration = iteration % targetIterations 80 | left = workerNumber * iterationsOwnedBy1Worker 81 | right = (workerNumber + 1) * iterationsOwnedBy1Worker 82 | ) 83 | if targetIterations == 1 { 84 | targetIterations = iterationsOwnedBy1Worker * uint64(t.cfg.Workers) 85 | if currentIteration == 0 { 86 | currentIteration = iteration % targetIterations 87 | } 88 | } 89 | if currentIteration < left || currentIteration >= right { 90 | return func(batchNumber uint64) bool { 91 | return false 92 | } 93 | } 94 | if t.cfg.Development { 95 | return func(batchNumber uint64) bool { 96 | return currentIteration == left 97 | } 98 | } 99 | 100 | return func(batchNumber uint64) bool { 101 | for i := uint64(0); i < batchesPerIterationsOwnedBy1Worker; i++ { 102 | if batchNumber == (((currentIteration - left) * batchesPerIterationsOwnedBy1Worker) + i) { 103 | return true 104 | } 105 | } 106 | if currentIteration == right-1 { 107 | for expectedBatchNumber := (batchesPerIterationsOwnedBy1Worker) * iterationsOwnedBy1Worker; expectedBatchNumber < totalBatches; expectedBatchNumber++ { 108 | if batchNumber == expectedBatchNumber { 109 | return true 110 | } 111 | } 112 | } 113 | 114 | return false 115 | } 116 | } 117 | 118 | func (t *telemetry) Printf(format string, args ...interface{}) { 119 | const prefixMarker = "timer " 120 | if strings.HasPrefix(format, prefixMarker) { 121 | t.currentStepName = fmt.Sprintf(format[len(prefixMarker):strings.IndexRune(format, '\n')], args...) 122 | } 123 | stdlog.Printf("["+t.currentStepName+"]"+strings.ReplaceAll(format, prefixMarker, ""), args...) 124 | } 125 | -------------------------------------------------------------------------------- /miner/metrics_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package miner 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | stdlibtime "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type ( 14 | expectedFunc func(worker, batch, iteration uint64) bool 15 | ) 16 | 17 | //nolint:funlen 18 | func TestShouldSynchronizeBalance(t *testing.T) { 19 | t.Parallel() 20 | t.Run("workers is greater than 1 batch in total", func(t *testing.T) { 21 | t.Parallel() 22 | iterateOverBatches(t, new(telemetry).mustInit(config{Workers: 800}), 1, 1000, trueOncePerWorkerIteration(t, 800, 1)) 23 | }) 24 | t.Run("workers is greater than 1 batch in total and processing is slowed", func(t *testing.T) { 25 | t.Parallel() 26 | tel := slowTelemetry(800) 27 | iterateOverBatches(t, tel, 1, 1000, trueOncePerWorkerIteration(t, 800, 1)) 28 | }) 29 | t.Run("workers is greater than 2 batch in total", func(t *testing.T) { 30 | t.Parallel() 31 | iterateOverBatches(t, new(telemetry).mustInit(config{Workers: 800}), 2, 1000, trueOncePerWorkerIteration(t, 800, 2)) 32 | }) 33 | t.Run("one iteration and multiple batches", func(t *testing.T) { 34 | t.Parallel() 35 | tel := slowTelemetry(800) 36 | iterateOverBatches(t, tel, 9, 1000, trueOncePerWorkerIterationPerBatch(t, 9, 800, 1, 9)) 37 | }) 38 | t.Run("multiple iterations and batches per worker", func(t *testing.T) { 39 | t.Parallel() 40 | tel := slowTelemetry(10) 41 | iterateOverBatches(t, tel, 25, 1000, trueOncePerWorkerIterationPerBatch(t, 25, 50, 5, 5)) 42 | }) 43 | t.Run("only one worker but a lot of batches", func(t *testing.T) { 44 | t.Parallel() 45 | iterateOverBatches(t, new(telemetry).mustInit(config{Workers: 1}), 100, 1000, trueOncePerWorkerIteration(t, 1, 100)) 46 | }) 47 | t.Run("only one worker and processing is slowed", func(t *testing.T) { 48 | t.Parallel() 49 | tel := slowTelemetry(1) 50 | iterateOverBatches(t, tel, 59, 1000, trueOncePerWorkerIteration(t, 1, 59)) 51 | }) 52 | t.Run("every batch is processed at least once", func(t *testing.T) { 53 | t.Parallel() 54 | maxWorkers := int64(70) 55 | tel := new(telemetry).mustInit(config{Workers: maxWorkers}) 56 | tel.collectElapsed(0, stdlibtime.Now().Add(-2*stdlibtime.Second)) 57 | count := 0 58 | for w := uint64(0); w < uint64(maxWorkers); w++ { 59 | for i := uint64(0); i < uint64(10000); i++ { 60 | shouldSync := tel.shouldSynchronizeBalanceFunc(w, 3, i) 61 | if shouldSync(0) { 62 | count += 1 63 | } 64 | if shouldSync(1) { 65 | count += 1 66 | } 67 | if shouldSync(2) { 68 | count += 1 69 | } 70 | } 71 | } 72 | assert.Equal(t, 10000, count) 73 | }) 74 | t.Run("previous workers waits in queue until all next is processed", func(t *testing.T) { 75 | t.Parallel() 76 | tel := slowTelemetry(400) 77 | checkPerWorkerAndIteration(t, tel, 0, 2, map[uint64]bool{ 78 | 0: true, 1: false, 2: false, 3: false, 4: false, 79 | 400: true, 401: false, 402: false, 80 | 799: false, 81 | 800: true, 801: false, 82 | }) 83 | checkPerWorkerAndIteration(t, tel, 1, 2, map[uint64]bool{ 84 | 0: false, 1: true, 2: false, 3: false, 4: false, 85 | 400: false, 401: true, 402: false, 86 | 800: false, 801: true, 802: false, 803: false, 87 | }) 88 | checkPerWorkerAndIteration(t, tel, 163, 2, map[uint64]bool{ 89 | 0: false, 1: false, 2: false, 3: false, 90 | 162: false, 163: true, 164: false, 91 | 400: false, 401: false, 402: false, 92 | 562: false, 563: true, 93 | 800: false, 801: false, 94 | 963: true, 95 | }) 96 | checkPerWorkerAndIteration(t, tel, 399, 2, map[uint64]bool{ 97 | 0: false, 1: false, 2: false, 3: false, 98 | 398: false, 399: true, 400: false, 99 | 797: false, 798: true, 799: false, 800: false, 100 | }) 101 | }) 102 | } 103 | 104 | func checkPerWorkerAndIteration(tb testing.TB, tel *telemetry, worker, totalBatches uint64, iterations map[uint64]bool) { 105 | tb.Helper() 106 | for i := uint64(0); i < uint64(len(iterations)); i++ { 107 | shouldSync := tel.shouldSynchronizeBalanceFunc(worker, totalBatches, i) 108 | for b := uint64(0); b < totalBatches; b++ { 109 | assert.Equal(tb, iterations[i], shouldSync(b), 110 | "iteration %v on worker %v should be %v (batch %v)", i, worker, iterations[i], b) 111 | } 112 | } 113 | 114 | } 115 | 116 | func iterateOverBatches(t testing.TB, tel *telemetry, totalBatches, iterations uint64, expected expectedFunc) { 117 | t.Helper() 118 | for w := uint64(0); w < uint64(tel.cfg.Workers); w++ { 119 | for i := uint64(0); i < iterations; i++ { 120 | shouldSyncBalance := tel.shouldSynchronizeBalanceFunc(w, totalBatches, i) 121 | for b := uint64(0); b < totalBatches; b++ { 122 | assert.Equal(t, expected(w, b, i), shouldSyncBalance(b), fmt.Sprintf("worker %v, batch %v, iteration %v", w, b, i)) 123 | } 124 | } 125 | } 126 | } 127 | 128 | func trueOncePerWorkerIterationPerBatch(tb testing.TB, totalBatches, totalIterations, iterationsPerWorker, batchesPerIteration uint64) expectedFunc { 129 | tb.Helper() 130 | iterations := make(map[string]bool) 131 | return func(worker, batch, iteration uint64) bool { 132 | iterationMatch := iteration%(totalIterations) >= (worker*iterationsPerWorker) && 133 | iteration%(totalIterations) < ((worker+1)*iterationsPerWorker) 134 | maxBatches := ((iteration + 1) % iterationsPerWorker * batchesPerIteration) 135 | if maxBatches == 0 { 136 | maxBatches = totalBatches 137 | } 138 | batchMatch := batch >= ((iteration%iterationsPerWorker)*batchesPerIteration)%totalBatches && batch < maxBatches 139 | 140 | res := iterationMatch && batchMatch 141 | if res { 142 | key := fmt.Sprintf("%v~%v", iteration, batch) 143 | _, dupl := iterations[key] 144 | assert.False(tb, dupl, fmt.Sprintf("duplicated true for call on iteration %v and batch %v (worker %v)", iteration, batch, worker)) 145 | iterations[key] = true 146 | } 147 | 148 | return res 149 | } 150 | } 151 | 152 | func trueOncePerWorkerIteration(tb testing.TB, totalWorkers, totalBatches uint64) expectedFunc { 153 | tb.Helper() 154 | iterations := make(map[uint64]bool) 155 | return func(worker, batch, iteration uint64) bool { 156 | res := (worker*totalBatches+batch)%(totalWorkers*totalBatches) == iteration%(totalWorkers*totalBatches) 157 | if res { 158 | _, dupl := iterations[iteration] 159 | assert.False(tb, dupl, fmt.Sprintf("%v", iteration)) 160 | iterations[iteration] = true 161 | } 162 | return res 163 | } 164 | } 165 | 166 | func slowTelemetry(workers int64) *telemetry { 167 | deadlineMultiplier := stdlibtime.Duration(24) 168 | tel := new(telemetry).mustInit(config{Workers: workers}) 169 | tel.collectElapsed(0, stdlibtime.Now().Add(-deadlineMultiplier*60*stdlibtime.Second)) 170 | tel.collectElapsed(1, stdlibtime.Now().Add(-deadlineMultiplier*50*stdlibtime.Second)) 171 | tel.collectElapsed(2, stdlibtime.Now().Add(-deadlineMultiplier*40*stdlibtime.Second)) 172 | tel.collectElapsed(3, stdlibtime.Now().Add(-deadlineMultiplier*30*stdlibtime.Second)) 173 | tel.collectElapsed(4, stdlibtime.Now().Add(-deadlineMultiplier*20*stdlibtime.Second)) 174 | tel.collectElapsed(5, stdlibtime.Now().Add(-deadlineMultiplier*10*stdlibtime.Second)) 175 | tel.collectElapsed(6, stdlibtime.Now().Add(-deadlineMultiplier*1*stdlibtime.Second)) 176 | tel.collectElapsed(7, stdlibtime.Now().Add(-deadlineMultiplier*1*stdlibtime.Second)) 177 | tel.collectElapsed(8, stdlibtime.Now().Add(-deadlineMultiplier*1*stdlibtime.Second)) 178 | 179 | return tel 180 | } 181 | -------------------------------------------------------------------------------- /miner/referral_lifecycle.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package miner 4 | 5 | import ( 6 | "github.com/ice-blockchain/wintr/time" 7 | ) 8 | 9 | func changeT0AndTMinus1Referrals(usr *user) (IDT0Changed, IDTMinus1Changed bool) { 10 | if usr.IDT0 <= 0 { 11 | usr.IDT0 *= -1 12 | usr.IDTMinus1 *= -1 13 | usr.BalanceT0, usr.BalanceForT0, usr.BalanceForT0Ethereum, usr.SlashingRateT0, usr.SlashingRateForT0 = 0, 0, 0, 0, 0 14 | usr.BalanceForTMinus1, usr.BalanceForTMinus1Ethereum, usr.SlashingRateForTMinus1 = 0, 0, 0 15 | usr.ResurrectT0UsedAt, usr.ResurrectTMinus1UsedAt = new(time.Time), new(time.Time) 16 | if usr.IDT0 != 0 { 17 | IDT0Changed = true 18 | } 19 | } else if usr.IDTMinus1 <= 0 { 20 | usr.IDTMinus1 *= -1 21 | usr.BalanceForTMinus1, usr.BalanceForTMinus1Ethereum, usr.SlashingRateForTMinus1 = 0, 0, 0 22 | usr.ResurrectTMinus1UsedAt = new(time.Time) 23 | if usr.IDTMinus1 != 0 { 24 | IDTMinus1Changed = true 25 | } 26 | } else { 27 | usr.IDT0 = 0 28 | usr.IDTMinus1 = 0 29 | } 30 | 31 | return IDT0Changed, IDTMinus1Changed 32 | } 33 | 34 | func didReferralJustStopMining(now *time.Time, before *user, t0Ref, tMinus1Ref *referral) *referralThatStoppedMining { 35 | if before == nil || 36 | before.MiningSessionSoloEndedAt.IsNil() || 37 | before.BalanceLastUpdatedAt.IsNil() || 38 | before.MiningSessionSoloEndedAt.After(*now.Time) || 39 | before.BalanceLastUpdatedAt.After(*before.MiningSessionSoloEndedAt.Time) { 40 | return nil 41 | } 42 | var idT0, idTminus1 int64 43 | if t0Ref != nil { 44 | idT0 = t0Ref.ID 45 | } 46 | if tMinus1Ref != nil { 47 | idTminus1 = tMinus1Ref.ID 48 | } 49 | 50 | return &referralThatStoppedMining{ 51 | ID: before.ID, 52 | IDT0: idT0, 53 | IDTMinus1: idTminus1, 54 | StoppedMiningAt: before.MiningSessionSoloEndedAt, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /miner/referral_lifecycle_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package miner 4 | 5 | import ( 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func Test_didReferralJustStopMining(t *testing.T) { 13 | t.Parallel() 14 | 15 | t.Run("EmptyData", func(t *testing.T) { 16 | x := didReferralJustStopMining(testTime, nil, nil, nil) 17 | require.Nil(t, x) 18 | }) 19 | 20 | t.Run("Stopped", func(t *testing.T) { 21 | before := newUser() 22 | before.BalanceLastUpdatedAt = timeDelta(-time.Hour * 2) 23 | before.MiningSessionSoloEndedAt = timeDelta(-time.Hour) 24 | 25 | x := didReferralJustStopMining(testTime, before, nil, nil) 26 | require.NotNil(t, x) 27 | require.NotNil(t, x.StoppedMiningAt.Time) 28 | }) 29 | 30 | t.Run("Full parameters list", func(t *testing.T) { 31 | before := newUser() 32 | before.BalanceLastUpdatedAt = timeDelta(-time.Hour * 2) 33 | before.MiningSessionSoloEndedAt = timeDelta(-time.Hour) 34 | 35 | t0Ref := newRef() 36 | tMinus1Ref := newRef() 37 | 38 | x := didReferralJustStopMining(testTime, before, t0Ref, tMinus1Ref) 39 | require.NotNil(t, x) 40 | require.NotNil(t, x.StoppedMiningAt.Time) 41 | require.Equal(t, before.ID, x.ID) 42 | require.Equal(t, t0Ref.ID, x.IDT0) 43 | require.Equal(t, tMinus1Ref.ID, x.IDTMinus1) 44 | }) 45 | 46 | t.Run("Only t0Ref", func(t *testing.T) { 47 | before := newUser() 48 | before.BalanceLastUpdatedAt = timeDelta(-time.Hour * 2) 49 | before.MiningSessionSoloEndedAt = timeDelta(-time.Hour) 50 | 51 | t0Ref := newRef() 52 | 53 | x := didReferralJustStopMining(testTime, before, t0Ref, nil) 54 | require.NotNil(t, x) 55 | require.NotNil(t, x.StoppedMiningAt.Time) 56 | require.Equal(t, before.ID, x.ID) 57 | require.Equal(t, t0Ref.ID, x.IDT0) 58 | require.Equal(t, int64(0), x.IDTMinus1) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /miner/resurrection.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package miner 4 | 5 | import ( 6 | "github.com/ice-blockchain/freezer/tokenomics" 7 | "github.com/ice-blockchain/wintr/time" 8 | ) 9 | 10 | func resurrect(now *time.Time, usr *user, t0Ref, tMinus1Ref *referral) (pendingResurrectionForTMinus1, pendingResurrectionForT0 float64) { 11 | if !usr.ResurrectSoloUsedAt.IsNil() && usr.ResurrectSoloUsedAt.After(*now.Time) { 12 | var resurrectDelta float64 13 | if timeSpent := usr.MiningSessionSoloStartedAt.Sub(*usr.MiningSessionSoloPreviouslyEndedAt.Time); cfg.Development { 14 | resurrectDelta = timeSpent.Minutes() 15 | } else { 16 | resurrectDelta = timeSpent.Hours() 17 | } 18 | 19 | usr.BalanceSolo += usr.SlashingRateSolo * resurrectDelta 20 | usr.BalanceT0 += usr.SlashingRateT0 * resurrectDelta 21 | mintedAmount := (usr.SlashingRateSolo + usr.SlashingRateT0) * resurrectDelta 22 | mintedStandard, mintedPreStaking := tokenomics.ApplyPreStaking(mintedAmount, usr.PreStakingAllocation, usr.PreStakingBonus) 23 | usr.BalanceTotalMinted += mintedStandard + mintedPreStaking 24 | 25 | usr.SlashingRateSolo, usr.SlashingRateT0 = 0, 0 26 | usr.ResurrectSoloUsedAt = now 27 | } else { 28 | usr.ResurrectSoloUsedAt = nil 29 | } 30 | 31 | if t0Ref != nil && !t0Ref.ResurrectSoloUsedAt.IsNil() && usr.ResurrectT0UsedAt.IsNil() { 32 | var resurrectDelta float64 33 | if timeSpent := t0Ref.MiningSessionSoloStartedAt.Sub(*t0Ref.MiningSessionSoloPreviouslyEndedAt.Time); cfg.Development { 34 | resurrectDelta = timeSpent.Minutes() 35 | } else { 36 | resurrectDelta = timeSpent.Hours() 37 | } 38 | 39 | amount := usr.SlashingRateForT0 * resurrectDelta 40 | usr.BalanceForT0 += amount 41 | pendingResurrectionForT0 += amount 42 | 43 | usr.SlashingRateForT0 = 0 44 | usr.ResurrectT0UsedAt = now 45 | } else { 46 | usr.ResurrectT0UsedAt = nil 47 | } 48 | 49 | if tMinus1Ref != nil && !tMinus1Ref.ResurrectSoloUsedAt.IsNil() && usr.ResurrectTMinus1UsedAt.IsNil() { 50 | var resurrectDelta float64 51 | if timeSpent := tMinus1Ref.MiningSessionSoloStartedAt.Sub(*tMinus1Ref.MiningSessionSoloPreviouslyEndedAt.Time); cfg.Development { 52 | resurrectDelta = timeSpent.Minutes() 53 | } else { 54 | resurrectDelta = timeSpent.Hours() 55 | } 56 | 57 | amount := usr.SlashingRateForTMinus1 * resurrectDelta 58 | usr.BalanceForTMinus1 += amount 59 | pendingResurrectionForTMinus1 += amount 60 | 61 | usr.SlashingRateForTMinus1 = 0 62 | usr.ResurrectTMinus1UsedAt = now 63 | } else { 64 | usr.ResurrectTMinus1UsedAt = nil 65 | } 66 | 67 | if usr.MiningSessionSoloEndedAt.After(*now.Time) { 68 | usr.SlashingRateSolo, usr.SlashingRateT0 = 0, 0 69 | } 70 | if usr.SlashingRateForT0 > 0 && (t0Ref == nil || t0Ref.MiningSessionSoloEndedAt.IsNil() || (t0Ref.MiningSessionSoloEndedAt.After(*now.Time) && usr.MiningSessionSoloEndedAt.After(*now.Time))) { 71 | usr.SlashingRateForT0 = 0 72 | } 73 | 74 | if usr.SlashingRateForTMinus1 > 0 && (tMinus1Ref == nil || tMinus1Ref.MiningSessionSoloEndedAt.IsNil() || (tMinus1Ref.MiningSessionSoloEndedAt.After(*now.Time) && usr.MiningSessionSoloEndedAt.After(*now.Time))) { //nolint:lll // . 75 | usr.SlashingRateForTMinus1 = 0 76 | } 77 | 78 | return pendingResurrectionForTMinus1, pendingResurrectionForT0 79 | } 80 | -------------------------------------------------------------------------------- /tokenomics/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: debug 7 | tokenomics: &tokenomics 8 | t1ReferralsAllowedWithoutAnyMiningBoostLevel: false 9 | tasksV2Enabled: false 10 | welcomeBonusV2Amount: 500 11 | wintr/connectors/storage/v3: 12 | url: redis://default:@localhost:6379 13 | bookkeeper/storage: 14 | runDDL: true 15 | urls: 16 | - localhost:9000 17 | db: default 18 | poolSize: 1 19 | credentials: 20 | user: default 21 | password: 22 | messageBroker: &tokenomicsMessageBroker 23 | consumerGroup: tokenomics-testing 24 | createTopics: true 25 | urls: 26 | - localhost:9093 27 | topics: &tokenomicsMessageBrokerTopics 28 | - name: freezer-health-check 29 | partitions: 1 30 | replicationFactor: 1 31 | retention: 1000h 32 | - name: adoption-table 33 | partitions: 10 34 | replicationFactor: 1 35 | retention: 1000h 36 | - name: mining-sessions-table 37 | partitions: 10 38 | replicationFactor: 1 39 | retention: 1000h 40 | - name: balances-table 41 | partitions: 10 42 | replicationFactor: 1 43 | retention: 1000h 44 | - name: available-daily-bonuses 45 | partitions: 10 46 | replicationFactor: 1 47 | retention: 1000h 48 | - name: started-days-off 49 | partitions: 10 50 | replicationFactor: 1 51 | retention: 1000h 52 | ### The next topics are not owned by this service, but are needed to be created for the local/test environment. 53 | - name: users-table 54 | partitions: 10 55 | replicationFactor: 1 56 | retention: 1000h 57 | - name: completed-tasks 58 | partitions: 10 59 | replicationFactor: 1 60 | retention: 1000h 61 | - name: viewed-news 62 | partitions: 10 63 | replicationFactor: 1 64 | retention: 1000h 65 | - name: user-device-metadata-table 66 | partitions: 10 67 | replicationFactor: 1 68 | retention: 1000h 69 | consumingTopics: 70 | - name: users-table 71 | - name: mining-sessions-table 72 | - name: completed-tasks 73 | - name: viewed-news 74 | - name: user-device-metadata-table 75 | wintr/multimedia/picture: 76 | urlDownload: https://ice-staging.b-cdn.net/profile 77 | referralBonusMiningRates: 78 | t0: 25 79 | t1: 25 80 | t2: 5 81 | rollbackNegativeMining: 82 | available: 83 | after: 1m 84 | until: 10m 85 | miningSessionDuration: 86 | min: 1m 87 | max: 2m 88 | warnAboutExpirationAfter: 100s 89 | consecutiveNaturalMiningSessionsRequiredFor1ExtraFreeArtificialMiningSession: 90 | min: 12 91 | max: 6 92 | globalAggregationInterval: 93 | parent: 60m 94 | child: 1m 95 | adoptionMilestoneSwitch: 96 | duration: 10s 97 | consecutiveDurationsRequired: 2 98 | activeUserMilestones: 99 | - users: 0 100 | baseMiningRate: 32 101 | - users: 2 102 | baseMiningRate: 16 103 | - users: 4 104 | baseMiningRate: 8 105 | - users: 6 106 | baseMiningRate: 4 107 | - users: 8 108 | baseMiningRate: 2 109 | - users: 10 110 | baseMiningRate: 1 111 | extraBonuses: 112 | duration: 24m 113 | utcOffsetDuration: 6s 114 | claimWindow: 1m 115 | delayedClaimPenaltyWindow: 15s 116 | availabilityWindow: 10m 117 | timeToAvailabilityWindow: 10m 118 | flatValues: 119 | - 2 120 | - 4 121 | - 6 122 | - 8 123 | - 10 124 | newsSeenValues: 125 | - 0 126 | - 6 127 | - 15 128 | - 54 129 | - 90 130 | miningStreakValues: 131 | - 0 132 | - 2 133 | - 5 134 | - 9 135 | - 20 136 | detailed-coin-metrics: 137 | refresh-interval: 10m 138 | t1LimitCount: 2 139 | tokenomics_test: 140 | <<: *tokenomics 141 | messageBroker: 142 | <<: *tokenomicsMessageBroker 143 | consumingTopics: *tokenomicsMessageBrokerTopics 144 | -------------------------------------------------------------------------------- /tokenomics/adoption.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package tokenomics 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "math" 9 | "strconv" 10 | "strings" 11 | stdlibtime "time" 12 | 13 | "github.com/hashicorp/go-multierror" 14 | "github.com/pkg/errors" 15 | "github.com/redis/go-redis/v9" 16 | 17 | "github.com/ice-blockchain/freezer/model" 18 | "github.com/ice-blockchain/wintr/connectors/storage/v3" 19 | "github.com/ice-blockchain/wintr/log" 20 | "github.com/ice-blockchain/wintr/time" 21 | ) 22 | 23 | func (r *repository) GetAdoptionSummary(ctx context.Context, userID string) (as *AdoptionSummary, err error) { 24 | if as = new(AdoptionSummary); ctx.Err() != nil { 25 | return nil, errors.Wrap(ctx.Err(), "context failed") 26 | } 27 | if as.TotalActiveUsers, err = r.db.Get(ctx, r.totalActiveUsersKey(*time.Now().Time)).Uint64(); err != nil && !errors.Is(err, redis.Nil) { 28 | return nil, errors.Wrap(err, "failed to get current totalActiveUsers") 29 | } 30 | as.Milestones = make([]*Adoption[string], 0, r.cfg.Adoption.Milestones) 31 | id, err := GetOrInitInternalID(ctx, r.db, userID, r.cfg.WelcomeBonusV2Amount) 32 | if err != nil { 33 | return nil, errors.Wrapf(err, "failed to getOrInitInternalID for userID:%v", userID) 34 | } 35 | res, err := storage.Get[struct{ model.CreatedAtField }](ctx, r.db, model.SerializedUsersKey(id)) 36 | if err != nil || len(res) == 0 { 37 | if err == nil { 38 | err = errors.Wrapf(ErrRelationNotFound, "missing state for id:%v", id) 39 | } 40 | 41 | return nil, errors.Wrapf(err, "failed to get GetAdoptionSummary for id:%v", id) 42 | } 43 | createdAt := res[0].CreatedAt 44 | if createdAt.IsNil() { 45 | createdAt = time.Now() 46 | } 47 | for mi := range r.cfg.Adoption.Milestones { 48 | achievedAt := time.New(createdAt.Add(stdlibtime.Duration(mi) * r.cfg.Adoption.DurationBetweenMilestones)) 49 | as.Milestones = append(as.Milestones, &Adoption[string]{ 50 | AchievedAt: achievedAt, 51 | BaseMiningRate: strconv.FormatFloat(BaseMiningRate(achievedAt, createdAt, r.cfg.Adoption.StartingBaseMiningRate, r.cfg.Adoption.Milestones, r.cfg.Adoption.DurationBetweenMilestones), 'f', 20, 64), 52 | Milestone: uint64(mi + 1), 53 | }) 54 | } 55 | 56 | return 57 | } 58 | 59 | func (r *repository) totalActiveUsersKey(date stdlibtime.Time) string { 60 | return fmt.Sprintf("%v:%v", totalActiveUsersGlobalKey, date.Format(r.cfg.globalAggregationIntervalChildDateFormat())) 61 | } 62 | 63 | func (r *repository) extractTimeFromTotalActiveUsersKey(key string) *time.Time { 64 | parseTime, err := stdlibtime.Parse(r.cfg.globalAggregationIntervalChildDateFormat(), strings.ReplaceAll(key, totalActiveUsersGlobalKey+":", "")) 65 | log.Panic(err) 66 | 67 | return time.New(parseTime) 68 | } 69 | 70 | func (r *repository) incrementTotalActiveUsers(ctx context.Context, ms *MiningSession) (err error) { //nolint:funlen // . 71 | duplGuardKey := ms.duplGuardKey(r, "incr_total_active_users") 72 | if set, dErr := r.db.SetNX(ctx, duplGuardKey, "", r.cfg.MiningSessionDuration.Min).Result(); dErr != nil || !set { 73 | if dErr == nil { 74 | dErr = ErrDuplicate 75 | } 76 | 77 | return errors.Wrapf(dErr, "SetNX failed for mining_session_dupl_guard, miningSession: %#v", ms) 78 | } 79 | defer func() { 80 | if err != nil { 81 | undoCtx, cancelUndo := context.WithTimeout(context.Background(), requestDeadline) 82 | defer cancelUndo() 83 | err = multierror.Append( //nolint:wrapcheck // . 84 | err, 85 | errors.Wrapf(r.db.Del(undoCtx, duplGuardKey).Err(), "failed to del mining_session_dupl_guard key"), 86 | ).ErrorOrNil() 87 | } 88 | }() 89 | keys := ms.detectIncrTotalActiveUsersKeys(r) 90 | responses, err := r.db.Pipelined(ctx, func(pipeliner redis.Pipeliner) error { 91 | for _, key := range keys { 92 | if err = pipeliner.Incr(ctx, key).Err(); err != nil { 93 | return err 94 | } 95 | } 96 | 97 | return nil 98 | }) 99 | if err == nil { 100 | errs := make([]error, 0, len(responses)) 101 | for _, response := range responses { 102 | errs = append(errs, errors.Wrapf(response.Err(), "failed to `%v`", response.FullName())) 103 | } 104 | err = multierror.Append(nil, errs...).ErrorOrNil() 105 | } 106 | 107 | return errors.Wrapf(err, "failed to incr total active users for keys:%#v", keys) 108 | } 109 | 110 | func (ms *MiningSession) detectIncrTotalActiveUsersKeys(repo *repository) []string { 111 | keys := make([]string, 0, int(repo.cfg.MiningSessionDuration.Max/repo.cfg.GlobalAggregationInterval.Child)) 112 | start, end := ms.EndedAt.Add(-ms.Extension), *ms.EndedAt.Time 113 | if !ms.LastNaturalMiningStartedAt.Equal(*ms.StartedAt.Time) || 114 | (!ms.PreviouslyEndedAt.IsNil() && 115 | repo.totalActiveUsersKey(*ms.StartedAt.Time) == repo.totalActiveUsersKey(*ms.PreviouslyEndedAt.Time)) { 116 | start = start.Add(repo.cfg.GlobalAggregationInterval.Child) 117 | } 118 | start = start.Truncate(repo.cfg.GlobalAggregationInterval.Child) 119 | end = end.Truncate(repo.cfg.GlobalAggregationInterval.Child) 120 | for start.Before(end) { 121 | keys = append(keys, repo.totalActiveUsersKey(start)) 122 | start = start.Add(repo.cfg.GlobalAggregationInterval.Child) 123 | } 124 | if ms.PreviouslyEndedAt.IsNil() || repo.totalActiveUsersKey(end) != repo.totalActiveUsersKey(*ms.PreviouslyEndedAt.Time) { 125 | keys = append(keys, repo.totalActiveUsersKey(end)) 126 | } 127 | 128 | return keys 129 | } 130 | 131 | func (c *Config) BaseMiningRate(now, createdAt *time.Time) float64 { 132 | return BaseMiningRate(now, createdAt, c.Adoption.StartingBaseMiningRate, c.Adoption.Milestones, c.Adoption.DurationBetweenMilestones) 133 | } 134 | 135 | func BaseMiningRate(now, createdAt *time.Time, startingBaseMiningRate float64, milestones uint8, durationBetweenMilestones stdlibtime.Duration) float64 { 136 | if createdAt.IsNil() || createdAt.Equal(*now.Time) || createdAt.After(*now.Time) { 137 | return startingBaseMiningRate 138 | } 139 | 140 | return startingBaseMiningRate / (math.Pow(2, math.Min(float64(milestones-1), float64(now.Sub(*createdAt.Time)/durationBetweenMilestones)))) 141 | } 142 | -------------------------------------------------------------------------------- /tokenomics/balance_total_coins_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package tokenomics 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | stdlibtime "time" 9 | 10 | "github.com/hashicorp/go-multierror" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | appCfg "github.com/ice-blockchain/wintr/config" 15 | "github.com/ice-blockchain/wintr/connectors/storage/v3" 16 | "github.com/ice-blockchain/wintr/time" 17 | ) 18 | 19 | func helperCreateRepoWithRedisOnly(t *testing.T) *repository { 20 | t.Helper() 21 | 22 | defer func() { 23 | if r := recover(); r != nil { 24 | t.Skip("skipping test; redis is not available") 25 | } 26 | }() 27 | 28 | var cfg Config 29 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 30 | 31 | db := storage.MustConnect(context.TODO(), applicationYamlKey) 32 | repo := &repository{ 33 | cfg: &cfg, 34 | shutdown: func() error { 35 | return multierror.Append(db.Close()).ErrorOrNil() 36 | }, 37 | db: db, 38 | } 39 | 40 | return repo 41 | } 42 | 43 | func TestGetCoinStatsBlockchainDetails(t *testing.T) { 44 | t.Parallel() 45 | 46 | repo := helperCreateRepoWithRedisOnly(t) 47 | 48 | t.Run("InvalidConfig", func(t *testing.T) { 49 | repo.cfg.DetailedCoinMetrics.RefreshInterval = 0 50 | require.Panics(t, func() { 51 | repo.keepBlockchainDetailsCacheUpdated(context.Background()) 52 | }) 53 | }) 54 | 55 | t.Run("ReadFromEmptyCache", func(t *testing.T) { 56 | _, err := repo.db.Del(context.TODO(), totalCoinStatsDetailsKey).Result() 57 | require.NoError(t, err) 58 | 59 | data, err := repo.loadCachedBlockchainDetails(context.TODO()) 60 | require.NoError(t, err) 61 | require.Nil(t, data) 62 | }) 63 | 64 | t.Run("FillFromKeeper", func(t *testing.T) { 65 | ctx, cancel := context.WithTimeout(context.Background(), stdlibtime.Second*2) 66 | defer cancel() 67 | 68 | repo.cfg.DetailedCoinMetrics.RefreshInterval = stdlibtime.Minute 69 | repo.keepBlockchainDetailsCacheUpdated(ctx) 70 | }) 71 | 72 | t.Run("CheckTimestampNoUpdate", func(t *testing.T) { 73 | err := repo.updateCachedBlockchainDetails(context.TODO()) 74 | require.NoError(t, err) 75 | }) 76 | 77 | t.Run("ReadCache", func(t *testing.T) { 78 | data, err := repo.loadCachedBlockchainDetails(context.TODO()) 79 | require.NoError(t, err) 80 | require.NotNil(t, data) 81 | require.Greater(t, data.CurrentPrice, 0.0) 82 | require.Greater(t, data.Volume24h, 0.0) 83 | require.NotNil(t, data.Timestamp) 84 | }) 85 | 86 | require.NoError(t, repo.Close()) 87 | } 88 | 89 | func TestTotalCoinsDates_HistoryGenerationDeltaPassed(t *testing.T) { 90 | t.Parallel() 91 | 92 | var cfg Config 93 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 94 | cfg.GlobalAggregationInterval.Parent = 24 * stdlibtime.Hour 95 | cfg.GlobalAggregationInterval.Child = 1 * stdlibtime.Hour 96 | repo := &repository{cfg: &cfg} 97 | 98 | now := time.New(stdlibtime.Date(2023, 7, 9, 5, 15, 10, 1, stdlibtime.UTC)) 99 | dates, timeSeries := repo.totalCoinsDates(now, 7) 100 | assert.Equal(t, []stdlibtime.Time{ 101 | now.Truncate(cfg.GlobalAggregationInterval.Parent), 102 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-1 * 24 * stdlibtime.Hour), 103 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-2 * 24 * stdlibtime.Hour), 104 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-3 * 24 * stdlibtime.Hour), 105 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-4 * 24 * stdlibtime.Hour), 106 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-5 * 24 * stdlibtime.Hour), 107 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-6 * 24 * stdlibtime.Hour), 108 | }, dates) 109 | assert.Equal(t, []*TotalCoinsTimeSeriesDataPoint{ 110 | { 111 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent), 112 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 113 | }, 114 | { 115 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-1 * 24 * stdlibtime.Hour), 116 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 117 | }, 118 | { 119 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-2 * 24 * stdlibtime.Hour), 120 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 121 | }, 122 | { 123 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-3 * 24 * stdlibtime.Hour), 124 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 125 | }, 126 | { 127 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-4 * 24 * stdlibtime.Hour), 128 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 129 | }, 130 | { 131 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-5 * 24 * stdlibtime.Hour), 132 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 133 | }, 134 | { 135 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-6 * 24 * stdlibtime.Hour), 136 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 137 | }, 138 | }, timeSeries) 139 | } 140 | 141 | func TestTotalCoinsDates_HistoryGenerationDeltaNotPassed(t *testing.T) { 142 | t.Parallel() 143 | 144 | var cfg Config 145 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 146 | cfg.GlobalAggregationInterval.Parent = 24 * stdlibtime.Hour 147 | cfg.GlobalAggregationInterval.Child = 1 * stdlibtime.Hour 148 | repo := &repository{cfg: &cfg} 149 | 150 | now := time.New(stdlibtime.Date(2023, 7, 9, 0, 15, 10, 1, stdlibtime.UTC)) 151 | dates, timeSeries := repo.totalCoinsDates(now, 7) 152 | assert.Equal(t, []stdlibtime.Time{ 153 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-1 * 24 * stdlibtime.Hour), 154 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-2 * 24 * stdlibtime.Hour), 155 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-3 * 24 * stdlibtime.Hour), 156 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-4 * 24 * stdlibtime.Hour), 157 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-5 * 24 * stdlibtime.Hour), 158 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-6 * 24 * stdlibtime.Hour), 159 | now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-7 * 24 * stdlibtime.Hour), 160 | }, dates) 161 | assert.Equal(t, []*TotalCoinsTimeSeriesDataPoint{ 162 | { 163 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-1 * 24 * stdlibtime.Hour), 164 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 165 | }, 166 | { 167 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-2 * 24 * stdlibtime.Hour), 168 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 169 | }, 170 | { 171 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-3 * 24 * stdlibtime.Hour), 172 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 173 | }, 174 | { 175 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-4 * 24 * stdlibtime.Hour), 176 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 177 | }, 178 | { 179 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-5 * 24 * stdlibtime.Hour), 180 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 181 | }, 182 | { 183 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-6 * 24 * stdlibtime.Hour), 184 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 185 | }, 186 | { 187 | Date: now.Truncate(cfg.GlobalAggregationInterval.Parent).Add(-7 * 24 * stdlibtime.Hour), 188 | TotalCoins: TotalCoins{Total: 0., Blockchain: 0., Standard: 0., PreStaking: 0.}, 189 | }, 190 | }, timeSeries) 191 | } 192 | -------------------------------------------------------------------------------- /tokenomics/detailed_coin_metrics/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: debug 7 | 8 | tokenomics/detailed-coin-metrics: 9 | # api-key: 10 | -------------------------------------------------------------------------------- /tokenomics/detailed_coin_metrics/api_client.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package detailed_coin_metrics //nolint:revive,nosnakecase,stylecheck //. 4 | 5 | import ( 6 | "context" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/imroc/req/v3" 11 | "github.com/pkg/errors" 12 | 13 | "github.com/ice-blockchain/wintr/log" 14 | ) 15 | 16 | func newAPIClient(key string) *apiClientImpl { 17 | return &apiClientImpl{ 18 | Key: key, 19 | } 20 | } 21 | 22 | func fetchFromAPI[T any](ctx context.Context, key, target string) (T, error) { 23 | const retryCount = 3 24 | var response struct { 25 | Data T `json:"data"` 26 | Status apiResponseStatus `json:"status"` 27 | } 28 | resp, err := req.DefaultClient().R().SetContext(ctx).SetRetryCount(retryCount). 29 | SetHeader("X-CMC_PRO_API_KEY", key). 30 | SetHeader("Accept", "application/json"). 31 | SetRetryHook(func(resp *req.Response, err error) { 32 | if err != nil { 33 | log.Error(errors.Wrap(err, "API: fetch failed")) 34 | } else { 35 | log.Warn("API: fetch failed: unexpected status code: " + resp.Status) 36 | } 37 | }). 38 | SetRetryCondition(func(resp *req.Response, err error) bool { 39 | return !(err == nil && resp.GetStatusCode() == http.StatusOK) 40 | }). 41 | SetErrorResult(&response).SetSuccessResult(&response).Get(target) 42 | switch { 43 | case err != nil: 44 | return response.Data, errors.Wrap(err, "cannot fetch data") 45 | case response.Status.ErrorCode != 0: 46 | return response.Data, errors.Wrapf(ErrAPIFailed, "message: %s (code %d)", response.Status.ErrorMessage, 47 | response.Status.ErrorCode) 48 | case resp.StatusCode != http.StatusOK: 49 | return response.Data, errors.Wrapf(ErrAPIFailed, "unexpected status code %d", resp.StatusCode) 50 | } 51 | 52 | return response.Data, nil 53 | } 54 | 55 | func (a *apiClientImpl) GetLatestQuote(ctx context.Context, slug, currency string) (map[int]apiResponseQuoteData, error) { 56 | const targetURL = `https://pro-api.coinmarketcap.com/v2/cryptocurrency/quotes/latest` 57 | 58 | parsed, err := url.Parse(targetURL) 59 | log.Panic(errors.Wrap(err, "cannot parse target URL")) //nolint:revive // False positive. 60 | 61 | query := parsed.Query() 62 | query.Set("slug", slug) 63 | if currency != "" { 64 | query.Set("convert", currency) 65 | } 66 | parsed.RawQuery = query.Encode() 67 | 68 | return fetchFromAPI[map[int]apiResponseQuoteData](ctx, a.Key, parsed.String()) 69 | } 70 | -------------------------------------------------------------------------------- /tokenomics/detailed_coin_metrics/api_client_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package detailed_coin_metrics //nolint:revive,nosnakecase,stylecheck //. 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestApiClientGetQuote(t *testing.T) { 14 | t.Parallel() 15 | 16 | key := os.Getenv("CMC_API_KEY") 17 | if key == "" { 18 | t.Skip("CMC_API_KEY is not set") 19 | } 20 | 21 | client := newAPIClient(key) 22 | require.NotNil(t, client) 23 | 24 | data, err := client.GetLatestQuote(context.Background(), iceSlug, targetCurrency) 25 | require.NoError(t, err) 26 | 27 | t.Logf("%+v", data) 28 | require.NotEmpty(t, data) 29 | require.Len(t, data, 1) 30 | require.Contains(t, data, iceID) 31 | require.Equal(t, iceSlug, data[iceID].Slug) 32 | require.Contains(t, data[iceID].Quote, targetCurrency) 33 | require.NotZero(t, data[iceID].Quote[targetCurrency].Price) 34 | require.NotZero(t, data[iceID].Quote[targetCurrency].Volume24h) 35 | } 36 | 37 | func TestApiClientBadCall(t *testing.T) { 38 | t.Parallel() 39 | 40 | client := newAPIClient("1234567890") 41 | require.NotNil(t, client) 42 | 43 | _, err := client.GetLatestQuote(context.Background(), iceSlug, targetCurrency) 44 | require.ErrorIs(t, err, ErrAPIFailed) 45 | } 46 | -------------------------------------------------------------------------------- /tokenomics/detailed_coin_metrics/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package detailed_coin_metrics //nolint:revive,nosnakecase,stylecheck //. 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | ) 9 | 10 | type ( 11 | ReadRepository interface { 12 | ReadDetails(ctx context.Context) (*Details, error) 13 | } 14 | Repository interface { 15 | ReadRepository 16 | } 17 | Details struct { 18 | CurrentPrice float64 `json:"currentPrice" redis:"currentPrice"` 19 | Volume24h float64 `json:"volume24h" redis:"volume24h"` 20 | } 21 | ) 22 | 23 | var ( //nolint:gofumpt //. 24 | ErrAPIFailed = errors.New("API call failed") 25 | ) 26 | 27 | const ( 28 | applicationYamlKey = "tokenomics/detailed-coin-metrics" 29 | iceSlug = "ice-decentralized-future" 30 | iceID = 27650 31 | targetCurrency = "USD" 32 | ) 33 | 34 | type ( 35 | config struct { 36 | APIKey string `yaml:"api-key" mapstructure:"api-key"` //nolint:tagliatelle,tagalign //. 37 | } 38 | repository struct { 39 | APIClient apiClient 40 | } 41 | apiClient interface { 42 | GetLatestQuote(ctx context.Context, slug, currency string) (map[int]apiResponseQuoteData, error) 43 | } 44 | apiClientImpl struct { 45 | Key string 46 | } 47 | apiResponseStatus struct { 48 | ErrorMessage string `json:"error_message"` //nolint:tagliatelle //. 49 | ErrorCode int `json:"error_code"` //nolint:tagliatelle //. 50 | } 51 | apiResponseQuoteCurrency struct { 52 | Price float64 `json:"price"` 53 | Volume24h float64 `json:"volume_24h"` //nolint:tagliatelle //. 54 | } 55 | apiResponseQuoteData struct { 56 | Quote map[string]apiResponseQuoteCurrency `json:"quote"` 57 | Name string `json:"name"` 58 | Slug string `json:"slug"` 59 | ID int `json:"id"` 60 | } 61 | ) 62 | -------------------------------------------------------------------------------- /tokenomics/detailed_coin_metrics/repository.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package detailed_coin_metrics //nolint:revive,nosnakecase,stylecheck //. 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/pkg/errors" 10 | 11 | appCfg "github.com/ice-blockchain/wintr/config" 12 | ) 13 | 14 | func loadConfig() *config { 15 | var cfg config 16 | 17 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 18 | 19 | if cfg.APIKey == "" { 20 | panic("api key is not set") 21 | } 22 | 23 | return &cfg 24 | } 25 | 26 | func New() Repository { 27 | return newRepository(loadConfig()) 28 | } 29 | 30 | func newRepository(conf *config) *repository { 31 | return &repository{ 32 | APIClient: newAPIClient(conf.APIKey), 33 | } 34 | } 35 | 36 | func (r *repository) ReadDetails(ctx context.Context) (*Details, error) { 37 | data, err := r.APIClient.GetLatestQuote(ctx, iceSlug, targetCurrency) 38 | if err != nil { 39 | return nil, errors.Wrap(err, "cannot fetch data from API") 40 | } 41 | 42 | quote, ok := data[iceID] 43 | if !ok { 44 | panic(fmt.Sprintf("unexpected API response: %+v", data)) 45 | } 46 | 47 | return &Details{ 48 | CurrentPrice: quote.Quote[targetCurrency].Price, 49 | Volume24h: quote.Quote[targetCurrency].Volume24h, 50 | }, nil 51 | } 52 | -------------------------------------------------------------------------------- /tokenomics/extra_bonus.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package tokenomics 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strings" 9 | stdlibtime "time" 10 | 11 | "github.com/goccy/go-json" 12 | "github.com/hashicorp/go-multierror" 13 | "github.com/pkg/errors" 14 | 15 | "github.com/ice-blockchain/freezer/model" 16 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 17 | "github.com/ice-blockchain/wintr/connectors/storage/v3" 18 | "github.com/ice-blockchain/wintr/time" 19 | ) 20 | 21 | type ( 22 | availableExtraBonus struct { 23 | model.ExtraBonusStartedAtField 24 | model.DeserializedUsersKey 25 | model.ExtraBonusField 26 | } 27 | ) 28 | 29 | func (r *repository) ClaimExtraBonus(ctx context.Context, ebs *ExtraBonusSummary) error { 30 | if ctx.Err() != nil { 31 | return errors.Wrap(ctx.Err(), "unexpected deadline") 32 | } 33 | id, err := GetOrInitInternalID(ctx, r.db, ebs.UserID, r.cfg.WelcomeBonusV2Amount) 34 | if err != nil { 35 | return errors.Wrapf(err, "failed to getOrInitInternalID for userID:%v", ebs.UserID) 36 | } 37 | now := time.Now() 38 | if r.cfg.ExtraBonuses.KycPassedExtraBonus == 0 { 39 | return ErrNotFound 40 | } 41 | stateForUpdate := &availableExtraBonus{ 42 | ExtraBonusStartedAtField: model.ExtraBonusStartedAtField{ExtraBonusStartedAt: now}, 43 | DeserializedUsersKey: model.DeserializedUsersKey{ID: id}, 44 | ExtraBonusField: model.ExtraBonusField{ExtraBonus: r.cfg.ExtraBonuses.KycPassedExtraBonus}, 45 | } 46 | ebs.AvailableExtraBonus = stateForUpdate.ExtraBonus 47 | 48 | return errors.Wrapf(storage.Set(ctx, r.db, stateForUpdate), "failed to claim extra bonus:%#v", stateForUpdate) 49 | } 50 | 51 | func (s *deviceMetadataTableSource) Process(ctx context.Context, msg *messagebroker.Message) error { //nolint:funlen // . 52 | if ctx.Err() != nil || len(msg.Value) == 0 { 53 | return errors.Wrap(ctx.Err(), "unexpected deadline while processing message") 54 | } 55 | type ( 56 | deviceMetadata struct { 57 | UserID string `json:"userId,omitempty" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 58 | TZ string `json:"tz,omitempty" example:"+03:00"` 59 | SystemName string `json:"systemName,omitempty" example:"Android"` 60 | ReadableVersion string `json:"readableVersion,omitempty" example:"9.9.9.2637"` 61 | } 62 | ) 63 | var dm deviceMetadata 64 | if err := json.UnmarshalContext(ctx, msg.Value, &dm); err != nil || dm.UserID == "" { 65 | return errors.Wrapf(err, "process: cannot unmarshall %v into %#v", string(msg.Value), &dm) 66 | } 67 | if dm.TZ == "" { 68 | dm.TZ = "+00:00" 69 | } 70 | duration, err := stdlibtime.ParseDuration(strings.Replace(dm.TZ+"m", ":", "h", 1)) 71 | if err != nil { 72 | return errors.Wrapf(err, "invalid timezone:%#v", &dm) 73 | } 74 | id, err := GetOrInitInternalID(ctx, s.db, dm.UserID, s.cfg.WelcomeBonusV2Amount) 75 | if err != nil { 76 | return errors.Wrapf(err, "failed to getOrInitInternalID for %#v", &dm) 77 | } 78 | sanitizedDeviceSystemName := strings.ReplaceAll(strings.ToLower(dm.SystemName), " ", "") 79 | val := &struct { 80 | model.LatestDeviceField 81 | model.DeserializedUsersKey 82 | model.UTCOffsetField 83 | }{ 84 | DeserializedUsersKey: model.DeserializedUsersKey{ID: id}, 85 | UTCOffsetField: model.UTCOffsetField{UTCOffset: int64(duration / stdlibtime.Minute)}, 86 | LatestDeviceField: model.LatestDeviceField{LatestDevice: fmt.Sprintf("%v:%v", sanitizedDeviceSystemName, dm.ReadableVersion)}, 87 | } 88 | if val.LatestDevice == ":" { 89 | val.LatestDevice = "" 90 | } 91 | 92 | return errors.Wrapf(storage.Set(ctx, s.db, val), "failed to update users' timezone for %#v", &dm) 93 | } 94 | 95 | func (s *viewedNewsSource) Process(ctx context.Context, msg *messagebroker.Message) (err error) { //nolint:funlen // . 96 | if ctx.Err() != nil || len(msg.Value) == 0 { 97 | return errors.Wrap(ctx.Err(), "unexpected deadline while processing message") 98 | } 99 | var vn struct { 100 | UserID string `json:"userId,omitempty" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 101 | NewsID string `json:"newsId,omitempty" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` 102 | } 103 | if err = json.UnmarshalContext(ctx, msg.Value, &vn); err != nil || vn.UserID == "" { 104 | return errors.Wrapf(err, "process: cannot unmarshall %v into %#v", string(msg.Value), &vn) 105 | } 106 | duplGuardKey := fmt.Sprintf("news_seen_dupl_guards:%v~%v", vn.UserID, vn.NewsID) 107 | if set, dErr := s.db.SetNX(ctx, duplGuardKey, "", s.cfg.MiningSessionDuration.Min).Result(); dErr != nil || !set { 108 | if dErr == nil { 109 | dErr = ErrDuplicate 110 | } 111 | 112 | return errors.Wrapf(dErr, "SetNX failed for news_seen_dupl_guard, %#v", vn) 113 | } 114 | defer func() { 115 | if err != nil { 116 | undoCtx, cancelUndo := context.WithTimeout(context.Background(), requestDeadline) 117 | defer cancelUndo() 118 | err = multierror.Append( //nolint:wrapcheck // . 119 | err, 120 | errors.Wrapf(s.db.Del(undoCtx, duplGuardKey).Err(), "failed to del news_seen_dupl_guard key"), 121 | ).ErrorOrNil() 122 | } 123 | }() 124 | id, err := GetOrInitInternalID(ctx, s.db, vn.UserID, s.cfg.WelcomeBonusV2Amount) 125 | if err != nil { 126 | return errors.Wrapf(err, "failed to getOrInitInternalID for %#v", &vn) 127 | } 128 | 129 | return errors.Wrapf(s.db.HIncrBy(ctx, model.SerializedUsersKey(id), "news_seen", 1).Err(), 130 | "failed to increment news_seen for userID:%v,id:%v", vn.UserID, id) 131 | } 132 | -------------------------------------------------------------------------------- /tokenomics/fixture/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package fixture 4 | 5 | // Public API. 6 | 7 | const ( 8 | TestConnectorsOrder = 0 9 | ) 10 | 11 | const ( 12 | All StartLocalTestEnvironmentType = "all" 13 | DB StartLocalTestEnvironmentType = "db" 14 | MB StartLocalTestEnvironmentType = "mb" 15 | ) 16 | 17 | type ( 18 | StartLocalTestEnvironmentType string 19 | ) 20 | 21 | // Private API. 22 | 23 | const ( 24 | applicationYAMLKey = "tokenomics" 25 | ) 26 | -------------------------------------------------------------------------------- /tokenomics/fixture/fixture.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package fixture 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/testcontainers/testcontainers-go" 9 | 10 | connectorsfixture "github.com/ice-blockchain/wintr/connectors/fixture" 11 | messagebrokerfixture "github.com/ice-blockchain/wintr/connectors/message_broker/fixture" 12 | storagefixture "github.com/ice-blockchain/wintr/connectors/storage/fixture" 13 | ) 14 | 15 | func StartLocalTestEnvironment(tp StartLocalTestEnvironmentType) { 16 | var connectors []connectorsfixture.TestConnector 17 | switch tp { 18 | case DB: 19 | connectors = append(connectors, newDBConnector()) 20 | case MB: 21 | connectors = append(connectors, newMBConnector()) 22 | case All: 23 | connectors = WTestConnectors() 24 | default: 25 | connectors = WTestConnectors() 26 | } 27 | connectorsfixture. 28 | NewTestRunner(applicationYAMLKey, nil, connectors...). 29 | StartConnectorsIndefinitely() 30 | } 31 | 32 | //nolint:gocritic // Because that's exactly what we want. 33 | func RunTests( 34 | m *testing.M, 35 | dbConnector *storagefixture.TestConnector, 36 | mbConnector *messagebrokerfixture.TestConnector, 37 | lifeCycleHooks ...*connectorsfixture.ConnectorLifecycleHooks, 38 | ) { 39 | *dbConnector = newDBConnector() 40 | *mbConnector = newMBConnector() 41 | 42 | var connectorLifecycleHooks *connectorsfixture.ConnectorLifecycleHooks 43 | if len(lifeCycleHooks) == 1 { 44 | connectorLifecycleHooks = lifeCycleHooks[0] 45 | } 46 | 47 | connectorsfixture. 48 | NewTestRunner(applicationYAMLKey, connectorLifecycleHooks, *dbConnector, *mbConnector). 49 | RunTests(m) 50 | } 51 | 52 | func WTestConnectors() []connectorsfixture.TestConnector { 53 | return []connectorsfixture.TestConnector{newDBConnector(), newMBConnector()} 54 | } 55 | 56 | func RTestConnectors() []connectorsfixture.TestConnector { 57 | return []connectorsfixture.TestConnector{newDBConnector()} 58 | } 59 | 60 | func newDBConnector() storagefixture.TestConnector { 61 | return storagefixture.NewTestConnector(applicationYAMLKey, TestConnectorsOrder) 62 | } 63 | 64 | func newMBConnector() messagebrokerfixture.TestConnector { 65 | return messagebrokerfixture.NewTestConnector(applicationYAMLKey, TestConnectorsOrder) 66 | } 67 | 68 | func RContainerMounts() []func(projectRoot string) testcontainers.ContainerMount { 69 | return nil 70 | } 71 | 72 | func WContainerMounts() []func(projectRoot string) testcontainers.ContainerMount { 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /tokenomics/globalDDL.sql: -------------------------------------------------------------------------------- 1 | -- SPDX-License-Identifier: ice License 1.0 2 | 3 | CREATE TABLE IF NOT EXISTS mining_boost_accepted_transactions ( 4 | created_at TIMESTAMP NOT NULL, 5 | mining_boost_level SMALLINT NOT NULL, 6 | tenant TEXT NOT NULL, 7 | tx_hash TEXT UNIQUE NOT NULL, 8 | ice_amount TEXT NOT NULL, 9 | payment_address TEXT NOT NULL DEFAULT '0x000000000000000000000000000000000000dead', 10 | sender_address TEXT NOT NULL, 11 | user_id TEXT NOT NULL, 12 | primary key(user_id,tx_hash)); 13 | ALTER TABLE mining_boost_accepted_transactions ADD COLUMN IF NOT EXISTS payment_address TEXT NOT NULL DEFAULT '0x000000000000000000000000000000000000dead'; -------------------------------------------------------------------------------- /tokenomics/kyc_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package tokenomics 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | stdlibtime "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/ice-blockchain/wintr/log" 13 | "github.com/ice-blockchain/wintr/time" 14 | ) 15 | 16 | func TestLoadBalanceKYCUsers(t *testing.T) { 17 | now := time.New(stdlibtime.Date(2024, 7, 16, 15, 00, 00, 0, stdlibtime.UTC)) 18 | startDate := time.New(now.Add(-1 * stdlibtime.Minute)) 19 | duration := 10 * stdlibtime.Minute 20 | miningDuration := 1 * stdlibtime.Minute 21 | assert.True(t, loadBalanceKYC(time.New(startDate.Add(1*stdlibtime.Second)), startDate, duration, miningDuration, 0)) 22 | assert.True(t, loadBalanceKYC(now, startDate, duration, miningDuration, 0)) 23 | assert.True(t, loadBalanceKYC(now, startDate, duration, miningDuration, 1)) 24 | assert.False(t, loadBalanceKYC(now, startDate, duration, miningDuration, 2)) 25 | assert.False(t, loadBalanceKYC(now, startDate, duration, miningDuration, 3)) 26 | assert.False(t, loadBalanceKYC(now, startDate, duration, miningDuration, 4)) 27 | assert.False(t, loadBalanceKYC(now, startDate, duration, miningDuration, 5)) 28 | assert.False(t, loadBalanceKYC(now, startDate, duration, miningDuration, 6)) 29 | assert.False(t, loadBalanceKYC(now, startDate, duration, miningDuration, 7)) 30 | assert.False(t, loadBalanceKYC(now, startDate, duration, miningDuration, 8)) 31 | assert.False(t, loadBalanceKYC(now, startDate, duration, miningDuration, 9)) 32 | assert.True(t, loadBalanceKYC(now, startDate, duration, miningDuration, 10)) 33 | assert.True(t, loadBalanceKYC(now, startDate, duration, miningDuration, 11)) 34 | assert.True(t, loadBalanceKYC(time.New(now.Add(1*stdlibtime.Minute)), startDate, duration, miningDuration, 11)) 35 | assert.True(t, loadBalanceKYC(time.New(now.Add(1*stdlibtime.Minute)), startDate, duration, miningDuration, 12)) 36 | assert.False(t, loadBalanceKYC(time.New(now.Add(1*stdlibtime.Minute)), startDate, duration, miningDuration, 13)) 37 | assert.True(t, loadBalanceKYC(time.New(now.Add(2*stdlibtime.Minute)), startDate, duration, miningDuration, 13)) 38 | assert.False(t, loadBalanceKYC(time.New(now.Add(2*stdlibtime.Minute)), startDate, duration, miningDuration, 14)) 39 | assert.True(t, loadBalanceKYC(time.New(now.Add(3*stdlibtime.Minute)), startDate, duration, miningDuration, 14)) 40 | } 41 | 42 | func TestLoadBalanceKYCUsersALotOfUsers(t *testing.T) { 43 | now := time.New(stdlibtime.Date(2024, 7, 16, 15, 00, 00, 0, stdlibtime.UTC)) 44 | startDate := time.New(stdlibtime.Date(2024, 7, 15, 15, 00, 00, 0, stdlibtime.UTC)) 45 | duration := 120 * stdlibtime.Hour 46 | miningDuration := 1 * stdlibtime.Minute 47 | breaked := false 48 | for i := range 1000000 { 49 | if !loadBalanceKYC(now, startDate, duration, miningDuration, int64(i)) { 50 | breaked = true 51 | log.Info(fmt.Sprintf("Stopped at %v at %v", i, now)) 52 | break 53 | } 54 | } 55 | assert.True(t, breaked) 56 | 57 | } 58 | -------------------------------------------------------------------------------- /tokenomics/mining_boost_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package tokenomics 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGenerateMiningBoostPaymentAddress(t *testing.T) { 12 | assert.Equal(t, "0x000000000000000000000000000000000000dead", generateMiningBoostPaymentAddress(0)) 13 | assert.Equal(t, "0x000000000000000000000000000000000001dead", generateMiningBoostPaymentAddress(1)) 14 | assert.Equal(t, "0x000000000000000000000000000000000002dead", generateMiningBoostPaymentAddress(2)) 15 | assert.Equal(t, "0x000000000000000000000000000000000010dead", generateMiningBoostPaymentAddress(10)) 16 | assert.Equal(t, "0x000000000000000000000000000001234567dead", generateMiningBoostPaymentAddress(1234567)) 17 | assert.Equal(t, "0x000000000000000000000000000010000000dead", generateMiningBoostPaymentAddress(10_000_000)) 18 | assert.Equal(t, "0x000000000000000000000000000011111111dead", generateMiningBoostPaymentAddress(11_111_111)) 19 | } 20 | -------------------------------------------------------------------------------- /tokenomics/mining_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package tokenomics 4 | 5 | import ( 6 | "testing" 7 | stdlibtime "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | appCfg "github.com/ice-blockchain/wintr/config" 12 | "github.com/ice-blockchain/wintr/time" 13 | ) 14 | 15 | func TestRepositoryCalculateMiningSession(t *testing.T) { 16 | t.Parallel() 17 | var cfg Config 18 | appCfg.MustLoadFromKey(applicationYamlKey, &cfg) 19 | repo := &repository{cfg: &cfg} 20 | 21 | now := time.Now() 22 | start := time.New(now.Add(-1 * stdlibtime.Second)) 23 | end := time.New(now.Add(repo.cfg.MiningSessionDuration.Max).Add(-1 * stdlibtime.Second)) 24 | actual := repo.calculateMiningSession(now, start, end, repo.cfg.MiningSessionDuration.Max) 25 | assert.EqualValues(t, start, actual.StartedAt) 26 | assert.False(t, *actual.Free) 27 | 28 | start = time.New(now.Add(-1 - repo.cfg.MiningSessionDuration.Min)) 29 | end = time.New(now.Add(repo.cfg.MiningSessionDuration.Max).Add(-1 - repo.cfg.MiningSessionDuration.Min)) 30 | actual = repo.calculateMiningSession(now, start, end, repo.cfg.MiningSessionDuration.Max) 31 | assert.EqualValues(t, start, actual.StartedAt) 32 | assert.False(t, *actual.Free) 33 | 34 | start = time.New(now.Add(-1 - repo.cfg.MiningSessionDuration.Max)) 35 | end = time.New(now.Add(repo.cfg.MiningSessionDuration.Max).Add(repo.cfg.MiningSessionDuration.Min).Add(-1 - repo.cfg.MiningSessionDuration.Max)) 36 | actual = repo.calculateMiningSession(now, start, end, repo.cfg.MiningSessionDuration.Max) 37 | assert.EqualValues(t, time.New(start.Add(repo.cfg.MiningSessionDuration.Max)), actual.StartedAt) 38 | assert.True(t, *actual.Free) 39 | 40 | boostedSessionDuration := 2 * repo.cfg.MiningSessionDuration.Max 41 | start = time.New(now.Add(-1 - boostedSessionDuration)) 42 | end = time.New(now.Add(boostedSessionDuration).Add(repo.cfg.MiningSessionDuration.Min).Add(-1 - boostedSessionDuration)) 43 | actual = repo.calculateMiningSession(now, start, end, boostedSessionDuration) 44 | assert.EqualValues(t, time.New(start.Add(boostedSessionDuration)), actual.StartedAt) 45 | assert.EqualValues(t, time.New(start.Add(boostedSessionDuration).Add(repo.cfg.MiningSessionDuration.Max)), actual.EndedAt) 46 | assert.True(t, *actual.Free) 47 | } 48 | -------------------------------------------------------------------------------- /tokenomics/pre_staking.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package tokenomics 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/pkg/errors" 9 | 10 | "github.com/ice-blockchain/freezer/model" 11 | "github.com/ice-blockchain/wintr/connectors/storage/v3" 12 | ) 13 | 14 | type ( 15 | preStaking struct { 16 | model.DeserializedUsersKey 17 | model.PreStakingBonusResettableField 18 | model.PreStakingAllocationResettableField 19 | } 20 | ) 21 | 22 | func (r *repository) GetPreStakingSummary(ctx context.Context, userID string) (*PreStakingSummary, error) { 23 | ps, _, err := r.getPreStaking(ctx, userID) 24 | if err != nil { 25 | return nil, errors.Wrapf(err, "failed to getPreStaking for userID:%v", userID) 26 | } 27 | 28 | return &PreStakingSummary{ 29 | PreStaking: &PreStaking{ 30 | Years: uint64(PreStakingYearsByPreStakingBonuses[ps.PreStakingBonus]), 31 | Allocation: ps.PreStakingAllocation, 32 | }, 33 | Bonus: ps.PreStakingBonus, 34 | }, nil 35 | } 36 | 37 | func (r *repository) getPreStaking(ctx context.Context, userID string) (*preStaking, int64, error) { 38 | id, err := GetOrInitInternalID(ctx, r.db, userID, r.cfg.WelcomeBonusV2Amount) 39 | if err != nil { 40 | return nil, 0, errors.Wrapf(err, "failed to getOrInitInternalID for userID:%v", userID) 41 | } 42 | usr, err := storage.Get[preStaking](ctx, r.db, model.SerializedUsersKey(id)) 43 | if err != nil || len(usr) == 0 || usr[0].PreStakingAllocation == 0 { 44 | if err == nil && (len(usr) == 0 || usr[0].PreStakingAllocation == 0) { 45 | err = ErrNotFound 46 | } 47 | 48 | return nil, id, errors.Wrapf(err, "failed to get pre-staking summary for id:%v", id) 49 | } 50 | 51 | return usr[0], id, nil 52 | } 53 | 54 | func (r *repository) StartOrUpdatePreStaking(ctx context.Context, st *PreStakingSummary) error { 55 | existing, id, err := r.getPreStaking(ctx, st.UserID) 56 | if err != nil && !errors.Is(err, ErrNotFound) { 57 | return errors.Wrapf(err, "failed to getPreStaking for userID:%v", st.UserID) 58 | } 59 | if existing != nil { 60 | existingYears := uint64(PreStakingYearsByPreStakingBonuses[existing.PreStakingBonus]) 61 | if existing.PreStakingAllocation == st.Allocation && existingYears == st.Years { 62 | st.Allocation = existing.PreStakingAllocation 63 | st.Years = existingYears 64 | st.Bonus = existing.PreStakingBonus 65 | 66 | return nil 67 | } 68 | } 69 | if st.Allocation == 0 || st.Years == 0 { 70 | st.Allocation = 0 71 | st.Years = 0 72 | st.Bonus = 0 73 | } else { 74 | st.Bonus = PreStakingBonusesPerYear[uint8(st.Years)] 75 | } 76 | 77 | existing = &preStaking{ 78 | DeserializedUsersKey: model.DeserializedUsersKey{ID: id}, 79 | PreStakingBonusResettableField: model.PreStakingBonusResettableField{PreStakingBonus: st.Bonus}, 80 | PreStakingAllocationResettableField: model.PreStakingAllocationResettableField{PreStakingAllocation: st.Allocation}, 81 | } 82 | 83 | return errors.Wrapf(storage.Set(ctx, r.db, existing), "failed to replace preStaking for %#v", st) 84 | } 85 | -------------------------------------------------------------------------------- /tokenomics/seeding/seeding.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | //go:build !test 4 | 5 | package seeding 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "strings" 11 | stdlibtime "time" 12 | 13 | "github.com/ice-blockchain/go-tarantool-client" 14 | "github.com/ice-blockchain/wintr/log" 15 | ) 16 | 17 | func StartSeeding() { 18 | before := stdlibtime.Now() 19 | db := dbConnector() 20 | defer func() { 21 | log.Panic(db.Close()) //nolint:revive // It doesnt really matter. 22 | log.Info(fmt.Sprintf("seeding finalized in %v", stdlibtime.Since(before).String())) 23 | }() 24 | log.Info("TODO: implement seeding") 25 | } 26 | 27 | func cleanUpWorkerSpaces(db tarantool.Connector) { //nolint:deadcode,unused // . 28 | tables := []string{ 29 | "balance_recalculation_worker_", 30 | "extra_bonus_processing_worker_", 31 | "blockchain_balance_synchronization_worker_", 32 | "mining_rates_recalculation_worker_", 33 | "balance_recalculation_worker_", 34 | "pre_stakings_", 35 | "mining_sessions_dlq_", 36 | } 37 | for _, table := range tables { 38 | for i := 0; i < 1000; i++ { 39 | sql := fmt.Sprintf(`delete from %[1]v%[2]v where 1=1`, table, i) 40 | _, err := db.PrepareExecute(sql, map[string]any{}) 41 | log.Panic(err) 42 | } 43 | } 44 | } 45 | 46 | func dbConnector() tarantool.Connector { 47 | parts := strings.Split(os.Getenv("MASTER_DB_INSTANCE_ADDRESS"), "@") 48 | userAndPass := strings.Split(parts[0], ":") 49 | opts := tarantool.Opts{ 50 | Timeout: 20 * stdlibtime.Second, //nolint:gomnd // It doesnt matter here. 51 | Reconnect: stdlibtime.Millisecond, 52 | MaxReconnects: 10, //nolint:gomnd // It doesnt matter here. 53 | User: userAndPass[0], 54 | Pass: userAndPass[1], 55 | } 56 | db, err := tarantool.Connect(parts[1], opts) 57 | log.Panic(err) 58 | 59 | return db 60 | } 61 | --------------------------------------------------------------------------------