diff --git a/README.md b/README.md index cba7f0c..c8f29a6 100644 --- a/README.md +++ b/README.md @@ -70,31 +70,43 @@ For more information on Jettons compatibility, see [Jettons compatibility](/jett ## Deployment ### Configurable parameters -| ENV variable | Description | -|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `LITESERVER` | IP and port of lite server, example: `185.86.76.183:5815` | -| `LITESERVER_KEY` | public key of lite server `5v2dHtSclsGsZVbNVwTj4hQDso5xvQjzL/yPEHJevHk=`.
Be careful with base64 encoding and ENV var. Use '' | -| `SEED` | seed phrase for main hot wallet. 24 words compatible with standard TON wallets | -| `DB_URI` | URI for DB connection, example:
`postgresql://db_user:db_password@localhost:5432/payment_processor` | -| `API_HOST` | host for REST API, example `localhost:8081`, default `0.0.0.0:8081` | -| `API_TOKEN` | Bearer token for REST API, example `123` | -| `IS_TESTNET` | `true` if service works in TESTNET, `false` - for MAINNET. Default: `true`. | -| `JETTONS` | list of Jettons, processed by service in format:
`JETTON_SYMBOL_1:MASTER_CONTRACT_ADDR_1:hot_wallet_max_balance:min_withdrawal_amount, JETTON_SYMBOL_2:MASTER_CONTRACT_ADDR_2:hot_wallet_max_balance:min_withdrawal_amount`,
example: `TGR:kQCKt2WPGX-fh0cIAz38Ljd_OKQjoZE_cqk7QrYGsNP6wfP0:1000000:100000` | -| `TON_CUTOFFS` | cutoffs in nanoTONs in format:
`hot_wallet_min_balance:hot_wallet_max_balance:min_withdrawal_amount`,
example `1000000000:100000000000:1000000000` | -| `COLD_WALLET` | cold-wallet address, example `kQCdyiS-fIV9UVfI9Phswo4l2MA-hm8YseH3XZ_YiH9Y1ufw` | -| `DEPOSIT_SIDE_BALANCE` | `true` - service calculates total income for user by deposit incoming, `false` - by hot wallet incoming. Default: `true`. | -| `QUEUE_ENABLED` | `true` - service sends incoming notifications to queue, `false` - sending disabled. Default: `false`. | -| `QUEUE_URI` | URI for queue client connection, example `amqp://guest:guest@payment_rabbitmq:5672/` | -| `QUEUE_NAME` | name of exchange | -| `WEBHOOK_ENDPOINT` | endpoint to send webhooks, example: `http://hostname:3333/webhook`. If the value is not set, then webhooks are not sent. | -| `WEBHOOK_TOKEN` | Bearer token for webhook request. If not set then not used. | -| `ALLOWABLE_LAG` | allowable time lag between service time and last block time in seconds, default: 15 | +| ENV variable | Description | +|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `LITESERVER` | IP and port of lite server, example: `185.86.76.183:5815` | +| `LITESERVER_KEY` | public key of lite server `5v2dHtSclsGsZVbNVwTj4hQDso5xvQjzL/yPEHJevHk=`.
Be careful with base64 encoding and ENV var. Use '' | +| `SEED` | seed phrase for main hot wallet. 24 words compatible with standard TON wallets | +| `DB_URI` | URI for DB connection, example:
`postgresql://db_user:db_password@localhost:5432/payment_processor` | +| `API_HOST` | host for REST API, example `localhost:8081`, default `0.0.0.0:8081` | +| `API_TOKEN` | Bearer token for REST API, example `123` | +| `IS_TESTNET` | `true` if service works in TESTNET, `false` - for MAINNET. Default: `true`. | +| `JETTONS` | list of Jettons, processed by service in format:
`JETTON_SYMBOL_1:MASTER_CONTRACT_ADDR_1:hot_wallet_max_balance:min_withdrawal_amount:hot_wallet_residual_balance, JETTON_SYMBOL_2:MASTER_CONTRACT_ADDR_2:hot_wallet_max_balance:min_withdrawal_amount:hot_wallet_residual_balance`,
example: `TGR:kQCKt2WPGX-fh0cIAz38Ljd_OKQjoZE_cqk7QrYGsNP6wfP0:1000000:100000` | +| `TON_CUTOFFS` | cutoffs in nanoTONs in format:
`hot_wallet_min_balance:hot_wallet_max_balance:min_withdrawal_amount:hot_wallet_residual_balance`,
example `1000000000:100000000000:1000000000:95000000000` | +| `COLD_WALLET` | cold-wallet address, example `kQCdyiS-fIV9UVfI9Phswo4l2MA-hm8YseH3XZ_YiH9Y1ufw`. If cold wallet is not active - use non-bounceable address (use https://ton.org/address for convert) | +| `DEPOSIT_SIDE_BALANCE` | `true` - service calculates total income for user by deposit incoming, `false` - by hot wallet incoming. Default: `true`. | +| `QUEUE_ENABLED` | `true` - service sends incoming notifications to queue, `false` - sending disabled. Default: `false`. | +| `QUEUE_URI` | URI for queue client connection, example `amqp://guest:guest@payment_rabbitmq:5672/` | +| `QUEUE_NAME` | name of exchange | +| `WEBHOOK_ENDPOINT` | endpoint to send webhooks, example: `http://hostname:3333/webhook`. If the value is not set, then webhooks are not sent. | +| `WEBHOOK_TOKEN` | Bearer token for webhook request. If not set then not used. | +| `ALLOWABLE_LAG` | allowable time lag between service time and last block time in seconds, default: 15 | **! Be careful with `IS_TESTNET` variable.** This does not guarantee that a testnet node is being used. It is only for address checking purposes. There are also internal service settings (fees and timeouts) that are specified in the source code in the [Config](/config/config.go) package. Calibration parameters recommendations in [Technical notes](/technical_notes.md). +#### `hot_wallet_residual_balance` and `hot_wallet_max_balance` + +In order to avoid triggering a withdrawal to a cold wallet with each receipt of funds, a hysteresis is introduced. +`hot_wallet_max_balance` - this is the amount at which the withdrawal from the hot wallet to the cold one will be triggered +`hot_wallet_residual_balance` is the amount that will remain on the hot wallet after the withdrawal + +`hot_wallet_max_balance` must be greater than `hot_wallet_residual_balance` + +If the `hot_wallet_residual_balance` is not set, then it is calculated using the formula: +`hot_wallet_residual_balance` = `hot_wallet_max_balance` * `hysteresis`, where hysteresis is a hardcoded value +(at the time of writing this is 0.95) + ### Service deploy **Do not use same `.env` file for `payment-processor` and other services!** @@ -136,7 +148,9 @@ Message format when `DEPOSIT_SIDE_BALANCE` == true: "time": 12345678, "amount":"100", "source_address":"0QAOp2OZwWdkF5HhJ0WVDspgh6HhpmHyQ3cBuBmfJ4q_AIVe", - "comment":"hello" + "comment":"hello", + "tx_hash": "f9b9e7efd3a38da318a894576499f0b6af5ca2da97ccd15c5f1d291a808a0ebf", + "user_id": "123" } ``` @@ -146,7 +160,9 @@ from the deposit): { "deposit_address":"0QCdsj-u39qVlfYdpPKuAY0hTe5VIsiJcpB5Rx4tOUOyBFhL", "time": 12345678, - "amount":"200" + "amount":"200", + "tx_hash": "f9b9e7efd3a38da318a894576499f0b6af5ca2da97ccd15c5f1d291a808a0ebf", + "user_id": "123" } ``` diff --git a/blockchain/blockchain_test.go b/blockchain/blockchain_test.go index a5c4886..709aee6 100644 --- a/blockchain/blockchain_test.go +++ b/blockchain/blockchain_test.go @@ -215,7 +215,7 @@ func Test_GetAccountCurrentState(t *testing.T) { func Test_DeployTonWallet(t *testing.T) { c := connect(t) seed := getSeed() - ctx, cancel := context.WithTimeout(context.Background(), time.Second*120) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*200) defer cancel() amount := tlb.FromNanoTONU(100_000_000) mainWallet, _, _, err := c.GenerateDefaultWallet(seed, true) @@ -229,7 +229,7 @@ func Test_DeployTonWallet(t *testing.T) { if b.Cmp(amount.NanoTON()) != 1 || st != tlb.AccountStatusActive { t.Fatal("wallet not active") } - newWallet, err := mainWallet.GetSubwallet(3567745334) + newWallet, err := mainWallet.GetSubwallet(rand.Uint32()) if err != nil { t.Fatal("gen new wallet err: ", err) } diff --git a/cmd/processor/main.go b/cmd/processor/main.go index 27c880e..265d967 100644 --- a/cmd/processor/main.go +++ b/cmd/processor/main.go @@ -54,7 +54,7 @@ func main() { wallets, err := core.InitWallets(ctx, dbClient, bcClient, config.Config.Seed, config.Config.Jettons) if err != nil { - log.Fatalf("Hot wallets initialization error: %v", err) + log.Fatalf("Wallets initialization error: %v", err) } var notificators []core.Notificator diff --git a/config/config.go b/config/config.go index 4cfb5d3..0997ada 100644 --- a/config/config.go +++ b/config/config.go @@ -16,6 +16,8 @@ var ( JettonTransferTonAmount = tlb.FromNanoTONU(100_000_000) JettonForwardAmount = tlb.FromNanoTONU(20_000_000) // must be < JettonTransferTonAmount + DefaultHotWalletHysteresis = decimal.NewFromFloat(0.95) // `hot_wallet_residual_balance` = `hot_wallet_max_balance` * `hysteresis` + ExternalMessageLifetime = 50 * time.Second ExternalWithdrawalPeriod = 80 * time.Second // must be ExternalWithdrawalPeriod > ExternalMessageLifetime and some time for balance update @@ -62,12 +64,14 @@ type Jetton struct { Master *address.Address WithdrawalCutoff *big.Int HotWalletMaxCutoff *big.Int + HotWalletResidual *big.Int } type Cutoffs struct { - HotWalletMin *big.Int - HotWalletMax *big.Int - Withdrawal *big.Int + HotWalletMin *big.Int + HotWalletMax *big.Int + Withdrawal *big.Int + HotWalletResidual *big.Int } func GetConfig() { @@ -119,7 +123,7 @@ func parseJettonString(s string) map[string]Jetton { jettons := strings.Split(s, ",") for _, j := range jettons { data := strings.Split(j, ":") - if len(data) != 4 { + if len(data) != 4 && len(data) != 5 { log.Fatalf("invalid jetton data") } cur := data[0] @@ -135,10 +139,20 @@ func parseJettonString(s string) map[string]Jetton { if err != nil { log.Fatalf("invalid %v jetton withdrawal cutoff: %v", data[0], err) } + + residual := maxCutoff.Mul(DefaultHotWalletHysteresis) + if len(data) == 5 { + residual, err = decimal.NewFromString(data[4]) + if err != nil { + log.Fatalf("invalid hot_wallet_residual_balance parameter: %v", err) + } + } + res[cur] = Jetton{ Master: addr, WithdrawalCutoff: withdrawalCutoff.BigInt(), HotWalletMaxCutoff: maxCutoff.BigInt(), + HotWalletResidual: residual.BigInt(), } } return res @@ -146,8 +160,8 @@ func parseJettonString(s string) map[string]Jetton { func parseTonString(s string) Cutoffs { data := strings.Split(s, ":") - if len(data) != 3 { - log.Fatalf("invalid jetton data") + if len(data) != 3 && len(data) != 4 { + log.Fatalf("invalid TON cuttofs") } hotWalletMin, err := decimal.NewFromString(data[0]) if err != nil { @@ -164,9 +178,19 @@ func parseTonString(s string) Cutoffs { if hotWalletMin.Cmp(hotWalletMax) == 1 { log.Fatalf("TON hot wallet max cutoff must be greater than TON hot wallet min cutoff") } + + residual := hotWalletMax.Mul(DefaultHotWalletHysteresis) + if len(data) == 4 { + residual, err = decimal.NewFromString(data[3]) + if err != nil { + log.Fatalf("invalid hot_wallet_residual_balance parameter: %v", err) + } + } + return Cutoffs{ - HotWalletMin: hotWalletMin.BigInt(), - HotWalletMax: hotWalletMax.BigInt(), - Withdrawal: withdrawal.BigInt(), + HotWalletMin: hotWalletMin.BigInt(), + HotWalletMax: hotWalletMax.BigInt(), + Withdrawal: withdrawal.BigInt(), + HotWalletResidual: residual.BigInt(), } } diff --git a/core/block_scanner.go b/core/block_scanner.go index 66f782a..172c9d5 100644 --- a/core/block_scanner.go +++ b/core/block_scanner.go @@ -58,6 +58,8 @@ type incomeNotification struct { Amount string `json:"amount"` Source string `json:"source_address,omitempty"` Comment string `json:"comment,omitempty"` + UserID string `json:"user_id"` + TxHash string `json:"tx_hash"` } func NewBlockScanner( @@ -133,14 +135,14 @@ func (s *BlockScanner) pushNotifications(e BlockEvents) error { if config.Config.IsDepositSideCalculation { for _, ei := range e.ExternalIncomes { - err := s.pushNotification(ei.To, ei.Amount, ei.Utime, ei.From, ei.FromWorkchain, ei.Comment) + err := s.pushNotification(ei.To, ei.Amount, ei.Utime, ei.From, ei.FromWorkchain, ei.Comment, ei.TxHash) if err != nil { return err } } } else { for _, ii := range e.InternalIncomes { - err := s.pushNotification(ii.From, ii.Amount, ii.Utime, nil, nil, "") + err := s.pushNotification(ii.From, ii.Amount, ii.Utime, nil, nil, "", ii.TxHash) if err != nil { return err } @@ -156,16 +158,23 @@ func (s *BlockScanner) pushNotification( from []byte, fromWorkchain *int32, comment string, + txHash []byte, ) error { owner := s.db.GetOwner(addr) if owner != nil { addr = *owner } + userID, ok := s.db.GetUserID(addr) + if !ok { + return fmt.Errorf("not found UserID for deposit %s", addr.ToUserFormat()) + } notification := incomeNotification{ Deposit: addr.ToUserFormat(), Amount: amount.String(), Timestamp: int64(timestamp), Comment: comment, + UserID: userID, + TxHash: fmt.Sprintf("%x", txHash), } if len(from) == 32 && fromWorkchain != nil { // supports only std address @@ -433,6 +442,7 @@ func convertUnknownJettonTxs(txs []*tlb.Transaction, addr Address, amount *big.I Lt: tx.LT, To: addr, Amount: ZeroCoins(), + TxHash: tx.Hash, }) } @@ -442,6 +452,7 @@ func convertUnknownJettonTxs(txs []*tlb.Transaction, addr Address, amount *big.I Lt: txs[0].LT, To: addr, Amount: NewCoins(amount), + TxHash: txs[0].Hash, }) } return incomes, nil @@ -746,6 +757,7 @@ func (s *BlockScanner) processTonHotWalletInternalInMsg(tx *tlb.Transaction) (Ev Amount: NewCoins(inMsg.Amount.NanoTON()), Memo: inMsg.Comment(), IsFailed: false, + TxHash: tx.Hash, } success, err := checkTxForSuccess(tx) if err != nil { @@ -777,6 +789,7 @@ func (s *BlockScanner) processTonHotWalletInternalInMsg(tx *tlb.Transaction) (Ev Amount: income.Amount, Memo: income.Comment, IsFailed: false, + TxHash: tx.Hash, }) } } @@ -854,6 +867,7 @@ func (s *BlockScanner) processTonDepositWalletInternalInMsg(tx *tlb.Transaction) To: dstAddr, Amount: NewCoins(inMsg.Amount.NanoTON()), Comment: inMsg.Comment(), + TxHash: tx.Hash, }) } return events, nil @@ -927,6 +941,7 @@ func (s *BlockScanner) processJettonDepositOutMsgs(tx *tlb.Transaction) (Events, To: srcAddr, Amount: notify.Amount, Comment: notify.Comment, + TxHash: tx.Hash, }) knownIncomeAmount.Add(knownIncomeAmount, notify.Amount.BigInt()) } diff --git a/core/models.go b/core/models.go index ebd3a5d..8572042 100644 --- a/core/models.go +++ b/core/models.go @@ -131,8 +131,9 @@ func AddressMustFromTonutilsAddress(addr *address.Address) Address { } type AddressInfo struct { - Type WalletType - Owner *Address + Type WalletType + Owner *Address + UserID string } type JettonWallet struct { @@ -222,6 +223,7 @@ type InternalIncome struct { Amount Coins Memo string IsFailed bool + TxHash []byte } type ExternalIncome struct { @@ -232,6 +234,7 @@ type ExternalIncome struct { To Address Amount Coins Comment string + TxHash []byte } type Events struct { @@ -297,6 +300,7 @@ type storage interface { SaveJettonWallet(ctx context.Context, ownerAddress Address, walletData WalletData, notSaveOwner bool) error GetWalletType(address Address) (WalletType, bool) GetOwner(address Address) *Address + GetUserID(address Address) (string, bool) GetWalletTypeByTonutilsAddress(address *address.Address) (WalletType, bool) SaveParsedBlockData(ctx context.Context, events BlockEvents) error GetTonInternalWithdrawalTasks(ctx context.Context, limit int) ([]InternalWithdrawalTask, error) diff --git a/core/wallets.go b/core/wallets.go index 2d97825..fb010ad 100644 --- a/core/wallets.go +++ b/core/wallets.go @@ -33,6 +33,18 @@ func InitWallets( seed string, jettons map[string]config.Jetton, ) (Wallets, error) { + + if config.Config.ColdWallet != nil && config.Config.ColdWallet.IsBounceable() { + _, status, err := bc.GetAccountCurrentState(ctx, config.Config.ColdWallet) + if err != nil { + return Wallets{}, err + } + log.Infof("Cold wallet status: %s", status) + if status != tlb.AccountStatusActive { + return Wallets{}, fmt.Errorf("cold wallet address must be non-bounceable for not active wallet") + } + } + tonHotWallet, shard, subwalletId, err := initTonHotWallet(ctx, db, bc, seed) if err != nil { return Wallets{}, err diff --git a/core/withdrawal_processor.go b/core/withdrawal_processor.go index 346aca1..fff43be 100644 --- a/core/withdrawal_processor.go +++ b/core/withdrawal_processor.go @@ -631,7 +631,7 @@ func (p *WithdrawalsProcessor) makeColdWalletWithdrawals(ctx context.Context) er if err != nil { return err } - jettonAmount.Sub(jettonBalance, config.Config.Jettons[cur].HotWalletMaxCutoff) + jettonAmount.Sub(jettonBalance, config.Config.Jettons[cur].HotWalletResidual) tonBalance.Sub(tonBalance, config.JettonTransferTonAmount.NanoTON()) req := WithdrawalRequest{ Currency: jw.Currency, @@ -665,7 +665,7 @@ func (p *WithdrawalsProcessor) makeColdWalletWithdrawals(ctx context.Context) er if err != nil { return err } - tonAmount.Sub(tonBalance, config.Config.Ton.HotWalletMax) + tonAmount.Sub(tonBalance, config.Config.Ton.HotWalletResidual) req := WithdrawalRequest{ Currency: TonSymbol, Amount: NewCoins(tonAmount), diff --git a/db/db.go b/db/db.go index 3935eec..21427b8 100644 --- a/db/db.go +++ b/db/db.go @@ -57,6 +57,11 @@ func (c *Connection) GetWalletType(address core.Address) (core.WalletType, bool) return info.Type, ok } +func (c *Connection) GetUserID(address core.Address) (string, bool) { + info, ok := c.addressBook.get(address) + return info.UserID, ok +} + // GetOwner returns owner for jetton deposit from address book and nil for other types func (c *Connection) GetOwner(address core.Address) *core.Address { info, ok := c.addressBook.get(address) @@ -103,7 +108,7 @@ func (c *Connection) SaveTonWallet(ctx context.Context, walletData core.WalletDa if err != nil { return err } - c.addressBook.put(walletData.Address, core.AddressInfo{Type: walletData.Type, Owner: nil}) + c.addressBook.put(walletData.Address, core.AddressInfo{Type: walletData.Type, Owner: nil, UserID: walletData.UserID}) return nil } @@ -186,7 +191,7 @@ func (c *Connection) SaveJettonWallet( // cold wallets excluded from address book c.addressBook.put(ownerAddress, core.AddressInfo{Type: core.JettonOwner, Owner: nil}) } - c.addressBook.put(walletData.Address, core.AddressInfo{Type: walletData.Type, Owner: &ownerAddress}) + c.addressBook.put(walletData.Address, core.AddressInfo{Type: walletData.Type, Owner: &ownerAddress, UserID: walletData.UserID}) return nil } @@ -258,12 +263,13 @@ func (c *Connection) GetJettonOwnersAddresses( func (c *Connection) LoadAddressBook(ctx context.Context) error { res := make(map[core.Address]core.AddressInfo) var ( - addr core.Address - t core.WalletType + addr core.Address + t core.WalletType + userID string ) rows, err := c.client.Query(ctx, ` - SELECT address, type + SELECT address, type, user_id FROM payments.ton_wallets `) if err != nil { @@ -271,15 +277,15 @@ func (c *Connection) LoadAddressBook(ctx context.Context) error { } defer rows.Close() for rows.Next() { - err = rows.Scan(&addr, &t) + err = rows.Scan(&addr, &t, &userID) if err != nil { return err } - res[addr] = core.AddressInfo{Type: t, Owner: nil} + res[addr] = core.AddressInfo{Type: t, Owner: nil, UserID: userID} } rows, err = c.client.Query(ctx, ` - SELECT jw.address, jw.type, tw.address + SELECT jw.address, jw.type, tw.address, jw.user_id FROM payments.jetton_wallets jw LEFT JOIN payments.ton_wallets tw ON jw.subwallet_id = tw.subwallet_id `) @@ -289,11 +295,11 @@ func (c *Connection) LoadAddressBook(ctx context.Context) error { defer rows.Close() for rows.Next() { var owner core.Address - err = rows.Scan(&addr, &t, &owner) + err = rows.Scan(&addr, &t, &owner, &userID) if err != nil { return err } - res[addr] = core.AddressInfo{Type: t, Owner: &owner} + res[addr] = core.AddressInfo{Type: t, Owner: &owner, UserID: userID} } c.addressBook.addresses = res diff --git a/manual_testing_plan.md b/manual_testing_plan.md index 3b95a7f..e6c0c0b 100644 --- a/manual_testing_plan.md +++ b/manual_testing_plan.md @@ -1,4 +1,4 @@ -## Manual testing plan for v0.2.0 +## Manual testing plan for v0.4.0 Template: -[x] Checked - TEST : test description @@ -57,6 +57,11 @@ Template: - RESULT : Service must stop. Must be address duplication error message in audit log. - COMMENT : +10. -[x] Checked +- TEST : Start service with uninitialized cold wallet and bounceable address for cold wallet. +- RESULT : Service must stop. Must be invalid address format error message in log. +- COMMENT : + ### API 1. -[x] Checked @@ -234,7 +239,7 @@ Template: should always be displayed. - COMMENT : -26. -[ ] Checked +26. -[x] Checked - TEST : Replenish the TON deposit from the masterchain wallet and check it by `/v1/history{?user_id,currency,limit,offset}` method. - RESULT : The sender's address must be displayed correctly in the history. @@ -249,7 +254,7 @@ Template: 28. -[x] Checked - TEST : Replenish the Jetton deposit with zero forward amount and check it by - `/v1/history{?user_id,currency,limit,offset}` method. + `/v1/history{?user_id,currency,limit,offset}` method. - RESULT : The sender's address must be not presented in the history. - COMMENT : @@ -257,9 +262,10 @@ Template: 1. -[x] Checked - TEST : Replenish the deposit with TONs and Jettons so that as a result the amount on the hot wallet is greater - than hot_wallet_max_balance. Check withdrawals in DB -- RESULT : You must find new withdrawal in `withdrawal_requests` table with `is_internal=true`. And final status - must correlate with explorer. + than `hot_wallet_max_balance` when cold wallet is not active and cold wallet address in non-bounceable format. + Check withdrawals in DB +- RESULT : You must find new withdrawal in `withdrawal_requests` table with `is_internal=true` and `bounceable=false`. + And final status must correlate with explorer. - COMMENT : 2. -[ ] Checked @@ -268,6 +274,14 @@ Template: - RESULT : There should be no missing blocks in the DB. - COMMENT : +3. -[x] Checked +- TEST : Replenish the deposit with TONs and Jettons so that as a result the amount on the hot wallet is greater + than `hot_wallet_max_balance`. Try with and without `hot_wallet_residual_balance` parameter. Check withdrawals in DB +- RESULT : You must find new withdrawal in `withdrawal_requests` table with `is_internal=true`. And final status + must correlate with explorer. Withdrawal amount must correlate with hysteresis formula + (and `hot_wallet_residual_balance` parameter). +- COMMENT : + ### Deploy 1. -[x] Checked @@ -319,7 +333,7 @@ Template: ### Stability test -1. -[ ] Checked +1. -[x] Checked - TEST : Start `payment-test` service using technical_notes.md instructions with `CIRCULATION=true` env variable for long time (with enough amount of test TONs on wallet). Periodically check availability and functionality of service by Grafana dashboard and docker logs. diff --git a/threat_model.md b/threat_model.md index f03b656..34f14c3 100644 --- a/threat_model.md +++ b/threat_model.md @@ -122,8 +122,24 @@ If TONs arrive at the wallet address at this time, the message will be applied a - D: warning about this behavior in technical_notes file for method description #### Setting the value to "expired" without taking into account the allowable delay -- P: It is impossible to absolutely precisely synchronize in time with the blockchain, so there is an +- P: it is impossible to absolutely precisely synchronize in time with the blockchain, so there is an allowable time delay value. If you get into this gap, the "expired" may be incorrectly set. - T: double spending for external withdrawals or unnecessary internal withdrawals - S: check expiration taking into account time delay -- D: check expiration taking into account time delay \ No newline at end of file +- D: check expiration taking into account time delay + +#### Repetitive failed transactions burning fees +- P: with periodic withdrawal cycles, there may be situations where the transaction fails every time. + For example, when withdrawing to an uninitialized cold wallet with the bounce flag. +- T: constant burning of a certain amount of TON on fees +- S: additional checks to predict the success of the transaction and additional messages in the audit log +- D: made an additional check on the state of the cold wallet and checking the bounce flag for withdrawal + +#### Too frequent withdrawals from a hot wallet to a cold wallet +- P: if you set only the maximum cutoff for funds on the hot wallet, then the withdrawal to the cold wallet will occur + if this amount is exceeded, even if the amount of the excess is less than the amount of the withdrawal fee +- T: there may be withdrawals of the amount of funds at which the amount of funds is unreasonably small, + which will lead to unnecessary burning of funds on fees +- S: it is necessary to set some delta between the amount of triggering the withdrawal to the cold wallet and the + amount that will remain after the withdrawal +- D: one more parameter has been added to the cutoffs - `hot_wallet_residual_balance` \ No newline at end of file diff --git a/todo_list.md b/todo_list.md index 86da434..7b99730 100644 --- a/todo_list.md +++ b/todo_list.md @@ -48,9 +48,15 @@ - [x] Add filling deposit with bounce to test plan - [x] Update to tonutils-go 1.6.2 - [x] Process masterchain addresses for external incomes +- [x] Cold wallet withdrawal fix +- [x] Add hysteresis to cold wallet withdrawal +- [x] Add user id to notifications +- [x] Add transaction hash to notifications +- [ ] Avoid blocking withdrawals to an address if there is a very large amount in the queue for withdrawals to this address +- [ ] Save tx hash to DB +- [ ] Support DNS names in recipient address - [ ] Jetton threat model - [ ] TNX compatibility test -- [ ] Support DNS names in recipient address - [ ] Installation video manual - [ ] Use stable branch for emulator - [ ] Download blockchain config at start @@ -63,3 +69,6 @@ - [ ] Not process removed Jettons - [ ] Separate .env files for services - [ ] Automatic migrations +- [ ] SDK +- [ ] migration from blueprint to openapi +- [ ] refactor config and cutoff parameters