From bdd05b0f39317e9ce428508fdcda0d66bd2a0ce1 Mon Sep 17 00:00:00 2001 From: riley-stride <104941670+riley-stride@users.noreply.github.com> Date: Sun, 12 Mar 2023 18:02:52 -0400 Subject: [PATCH] Cleanup Stakeibc Hooks + Test (#648) Co-authored-by: Hieu Vu <72878483+hieuvubk@users.noreply.github.com> --- app/app.go | 23 +- dockernet/config.sh | 1 + dockernet/src/init_chain.sh | 1 + dockernet/tests/integration_tests.bats | 13 + go.mod | 26 +- go.sum | 48 +-- x/interchainquery/keeper/queries.go | 42 ++- x/interchainquery/keeper/queries_test.go | 124 +++++++ x/interchainquery/types/keys.go | 4 +- x/stakeibc/keeper/hooks.go | 46 +-- x/stakeibc/keeper/host_zone.go | 10 + x/stakeibc/keeper/icacallbacks_reinvest.go | 43 ++- .../keeper/icacallbacks_reinvest_test.go | 104 +++++- x/stakeibc/keeper/icqcallbacks.go | 4 +- x/stakeibc/keeper/icqcallbacks_fee_balance.go | 92 +++++ .../keeper/icqcallbacks_fee_balance_test.go | 226 ++++++++++++ .../keeper/icqcallbacks_withdrawal_balance.go | 59 +--- .../icqcallbacks_withdrawal_balance_test.go | 113 ------ x/stakeibc/keeper/msg_server_submit_tx.go | 6 - x/stakeibc/keeper/reward_allocation.go | 85 +++++ x/stakeibc/keeper/reward_allocation_test.go | 330 +++++++++--------- x/stakeibc/types/errors.go | 5 +- 22 files changed, 952 insertions(+), 453 deletions(-) create mode 100644 x/interchainquery/keeper/queries_test.go create mode 100644 x/stakeibc/keeper/icqcallbacks_fee_balance.go create mode 100644 x/stakeibc/keeper/icqcallbacks_fee_balance_test.go create mode 100644 x/stakeibc/keeper/reward_allocation.go diff --git a/app/app.go b/app/app.go index 3bd6ddea01..05aa6c96de 100644 --- a/app/app.go +++ b/app/app.go @@ -216,15 +216,15 @@ var ( authtypes.FeeCollectorName: nil, distrtypes.ModuleName: nil, // mint module needs burn access to remove excess validator tokens (it overallocates, then burns) - minttypes.ModuleName: {authtypes.Minter, authtypes.Burner}, - stakingtypes.BondedPoolName: {authtypes.Burner, authtypes.Staking}, - stakingtypes.NotBondedPoolName: {authtypes.Burner, authtypes.Staking}, - govtypes.ModuleName: {authtypes.Burner}, - ibctransfertypes.ModuleName: {authtypes.Minter, authtypes.Burner}, - stakeibcmoduletypes.ModuleName: {authtypes.Minter, authtypes.Burner, authtypes.Staking}, - claimtypes.ModuleName: nil, - interchainquerytypes.ModuleName: nil, - icatypes.ModuleName: nil, + minttypes.ModuleName: {authtypes.Minter, authtypes.Burner}, + stakingtypes.BondedPoolName: {authtypes.Burner, authtypes.Staking}, + stakingtypes.NotBondedPoolName: {authtypes.Burner, authtypes.Staking}, + govtypes.ModuleName: {authtypes.Burner}, + ibctransfertypes.ModuleName: {authtypes.Minter, authtypes.Burner}, + stakeibcmoduletypes.ModuleName: {authtypes.Minter, authtypes.Burner, authtypes.Staking}, + claimtypes.ModuleName: nil, + interchainquerytypes.ModuleName: nil, + icatypes.ModuleName: nil, stakeibcmoduletypes.RewardCollectorName: nil, // this line is used by starport scaffolding # stargate/app/maccPerms } @@ -952,10 +952,7 @@ func (app *StrideApp) BlacklistedModuleAccountAddrs() map[string]bool { // DO NOT REMOVE: StringMapKeys fixes non-deterministic map iteration for _, acc := range utils.StringMapKeys(maccPerms) { // don't blacklist stakeibc module account, so that it can ibc transfer tokens - if acc == "stakeibc" { - continue - } - if acc == stakeibcmoduletypes.RewardCollectorName { + if acc == stakeibcmoduletypes.ModuleName || acc == stakeibcmoduletypes.RewardCollectorName { continue } modAccAddrs[authtypes.NewModuleAddress(acc).String()] = true diff --git a/dockernet/config.sh b/dockernet/config.sh index 7f6e0702a1..bfb9418eb3 100644 --- a/dockernet/config.sh +++ b/dockernet/config.sh @@ -94,6 +94,7 @@ BLOCK_TIME='1s' STRIDE_HOUR_EPOCH_DURATION="90s" STRIDE_DAY_EPOCH_DURATION="100s" STRIDE_EPOCH_EPOCH_DURATION="40s" +STRIDE_MINT_EPOCH_DURATION="20s" HOST_DAY_EPOCH_DURATION="60s" HOST_HOUR_EPOCH_DURATION="60s" HOST_WEEK_EPOCH_DURATION="60s" diff --git a/dockernet/src/init_chain.sh b/dockernet/src/init_chain.sh index 2a5fb3a313..37ea0eaea8 100644 --- a/dockernet/src/init_chain.sh +++ b/dockernet/src/init_chain.sh @@ -24,6 +24,7 @@ set_stride_genesis() { jq '(.app_state.epochs.epochs[] | select(.identifier=="day") ).duration = $epochLen' --arg epochLen $STRIDE_DAY_EPOCH_DURATION $genesis_config > json.tmp && mv json.tmp $genesis_config jq '(.app_state.epochs.epochs[] | select(.identifier=="hour") ).duration = $epochLen' --arg epochLen $STRIDE_HOUR_EPOCH_DURATION $genesis_config > json.tmp && mv json.tmp $genesis_config jq '(.app_state.epochs.epochs[] | select(.identifier=="stride_epoch") ).duration = $epochLen' --arg epochLen $STRIDE_EPOCH_EPOCH_DURATION $genesis_config > json.tmp && mv json.tmp $genesis_config + jq '(.app_state.epochs.epochs[] | select(.identifier=="mint") ).duration = $epochLen' --arg epochLen $STRIDE_MINT_EPOCH_DURATION $genesis_config > json.tmp && mv json.tmp $genesis_config jq '.app_state.staking.params.unbonding_time = $newVal' --arg newVal "$UNBONDING_TIME" $genesis_config > json.tmp && mv json.tmp $genesis_config jq '.app_state.gov.deposit_params.max_deposit_period = $newVal' --arg newVal "$MAX_DEPOSIT_PERIOD" $genesis_config > json.tmp && mv json.tmp $genesis_config jq '.app_state.gov.voting_params.voting_period = $newVal' --arg newVal "$VOTING_PERIOD" $genesis_config > json.tmp && mv json.tmp $genesis_config diff --git a/dockernet/tests/integration_tests.bats b/dockernet/tests/integration_tests.bats index 92ce97ec53..e5a30f5402 100644 --- a/dockernet/tests/integration_tests.bats +++ b/dockernet/tests/integration_tests.bats @@ -234,3 +234,16 @@ setup_file() { redemption_rate_increased=$(( $(FLOOR $(DECMUL $redemption_rate $MULT)) > $(FLOOR $(DECMUL 1.00000000000000000 $MULT)))) assert_equal "$redemption_rate_increased" "1" } + +# rewards have been collected and distributed to strd stakers +@test "[INTEGRATION-BASIC-$CHAIN_NAME] rewards are being distributed to stakers" { + # collect the 2nd validator's outstanding rewards + val_address=$($STRIDE_MAIN_CMD keys show ${STRIDE_VAL_PREFIX}2 --keyring-backend test -a) + $STRIDE_MAIN_CMD tx distribution withdraw-all-rewards --from ${STRIDE_VAL_PREFIX}2 -y + WAIT_FOR_BLOCK $STRIDE_LOGS 2 + + # confirm they've recieved stTokens + sttoken_balance=$($STRIDE_MAIN_CMD q bank balances $val_address --denom st$HOST_DENOM | GETBAL) + rewards_accumulated=$(($sttoken_balance > 0)) + assert_equal "$rewards_accumulated" "1" +} diff --git a/go.mod b/go.mod index f98170b62c..655d508475 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,9 @@ go 1.19 require ( cosmossdk.io/errors v1.0.0-beta.7 cosmossdk.io/math v1.0.0-beta.3 - github.com/cosmos/cosmos-proto v1.0.0-alpha8 + github.com/cosmos/cosmos-proto v1.0.0-beta.1 github.com/cosmos/cosmos-sdk v0.46.7 - github.com/cosmos/gogoproto v1.4.3 + github.com/cosmos/gogoproto v1.4.4 github.com/cosmos/ibc-go/v5 v5.1.0 github.com/gogo/protobuf v1.3.3 github.com/golang/protobuf v1.5.2 @@ -19,16 +19,16 @@ require ( github.com/stretchr/testify v1.8.1 github.com/tendermint/tendermint v0.34.24 github.com/tendermint/tm-db v0.6.7 - google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 - google.golang.org/grpc v1.51.0 + google.golang.org/genproto v0.0.0-20230202175211-008b39050e57 + google.golang.org/grpc v1.53.0 gopkg.in/yaml.v2 v2.4.0 ) require ( cloud.google.com/go v0.105.0 // indirect - cloud.google.com/go/compute v1.12.1 // indirect + cloud.google.com/go/compute v1.13.0 // indirect cloud.google.com/go/compute/metadata v0.2.1 // indirect - cloud.google.com/go/iam v0.7.0 // indirect + cloud.google.com/go/iam v0.8.0 // indirect cloud.google.com/go/storage v1.27.0 // indirect filippo.io/edwards25519 v1.0.0-rc.1 // indirect github.com/99designs/keyring v1.2.1 // indirect @@ -77,7 +77,7 @@ require ( github.com/google/orderedcode v0.0.1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect - github.com/googleapis/gax-go/v2 v2.6.0 // indirect + github.com/googleapis/gax-go/v2 v2.7.0 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect @@ -138,16 +138,16 @@ require ( github.com/zondax/hid v0.9.1 // indirect github.com/zondax/ledger-go v0.14.0 // indirect go.etcd.io/bbolt v1.3.6 // indirect - go.opencensus.io v0.23.0 // indirect + go.opencensus.io v0.24.0 // indirect golang.org/x/crypto v0.2.0 // indirect golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect - golang.org/x/net v0.2.0 // indirect + golang.org/x/net v0.5.0 // indirect golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 // indirect - golang.org/x/sys v0.2.0 // indirect - golang.org/x/term v0.2.0 // indirect - golang.org/x/text v0.4.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/term v0.4.0 // indirect + golang.org/x/text v0.6.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/api v0.102.0 // indirect + google.golang.org/api v0.103.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.2-0.20220831092852-f930b1dc76e8 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 80a68050e4..3ee4db665a 100644 --- a/go.sum +++ b/go.sum @@ -27,14 +27,14 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/bigtable v1.2.0/go.mod h1:JcVAOl45lrTmQfLj7T6TxyMzIN/3FGGcFm+2xVAli2o= -cloud.google.com/go/compute v1.12.1 h1:gKVJMEyqV5c/UnpzjjQbo3Rjvvqpr9B1DFSbJC4OXr0= -cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= +cloud.google.com/go/compute v1.13.0 h1:AYrLkB8NPdDRslNp4Jxmzrhdr03fUAIDbiGFjLWowoU= +cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARyZtRXDJ8GE= cloud.google.com/go/compute/metadata v0.2.1 h1:efOwf5ymceDhK6PKMnnrTHP4pppY5L22mle96M1yP48= cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/iam v0.7.0 h1:k4MuwOsS7zGJJ+QfZ5vBK8SgHBAvYN/23BWsiihJ1vs= -cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg= +cloud.google.com/go/iam v0.8.0 h1:E2osAkZzxI/+8pZcxVLcDtAQx/u+hZXVryUaYQ5O0Kk= +cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE= cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= @@ -205,8 +205,8 @@ github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cosmos/btcutil v1.0.5 h1:t+ZFcX77LpKtDBhjucvnOH8C2l2ioGsBNEQ3jef8xFk= github.com/cosmos/btcutil v1.0.5/go.mod h1:IyB7iuqZMJlthe2tkIFL33xPyzbFYP0XVdS8P5lUPis= -github.com/cosmos/cosmos-proto v1.0.0-alpha8 h1:d3pCRuMYYvGA5bM0ZbbjKn+AoQD4A7dyNG2wzwWalUw= -github.com/cosmos/cosmos-proto v1.0.0-alpha8/go.mod h1:6/p+Bc4O8JKeZqe0VqUGTX31eoYqemTT4C1hLCWsO7I= +github.com/cosmos/cosmos-proto v1.0.0-beta.1 h1:iDL5qh++NoXxG8hSy93FdYJut4XfgbShIocllGaXx/0= +github.com/cosmos/cosmos-proto v1.0.0-beta.1/go.mod h1:8k2GNZghi5sDRFw/scPL8gMSowT1vDA+5ouxL8GjaUE= github.com/cosmos/cosmos-sdk v0.46.7 h1:dkGy9y2ewgqvawrUOuWb2oz3MdotVduokyreXC4bS0s= github.com/cosmos/cosmos-sdk v0.46.7/go.mod h1:fqKqz39U5IlEFb4nbQ72951myztsDzFKKDtffYJ63nk= github.com/cosmos/cosmos-sdk/ics23/go v0.8.0 h1:iKclrn3YEOwk4jQHT2ulgzuXyxmzmPczUalMwW4XH9k= @@ -214,8 +214,8 @@ github.com/cosmos/cosmos-sdk/ics23/go v0.8.0/go.mod h1:2a4dBq88TUoqoWAU5eu0lGvpF github.com/cosmos/go-bip39 v0.0.0-20180819234021-555e2067c45d/go.mod h1:tSxLoYXyBmiFeKpvmq4dzayMdCjCnu8uqmCysIGBT2Y= github.com/cosmos/go-bip39 v1.0.0 h1:pcomnQdrdH22njcAatO0yWojsUnCO3y2tNoV1cb6hHY= github.com/cosmos/go-bip39 v1.0.0/go.mod h1:RNJv0H/pOIVgxw6KS7QeX2a0Uo0aKUlfhZ4xuwvCdJw= -github.com/cosmos/gogoproto v1.4.3 h1:RP3yyVREh9snv/lsOvmsAPQt8f44LgL281X0IOIhhcI= -github.com/cosmos/gogoproto v1.4.3/go.mod h1:0hLIG5TR7IvV1fme1HCFKjfzW9X2x0Mo+RooWXCnOWU= +github.com/cosmos/gogoproto v1.4.4 h1:nVAsgLlAf5jeN0fV7hRlkZvf768zU+dy4pG+hxc2P34= +github.com/cosmos/gogoproto v1.4.4/go.mod h1:/yl6/nLwsZcZ2JY3OrqjRqvqCG9InUMcXRfRjQiF9DU= github.com/cosmos/gorocksdb v1.2.0 h1:d0l3jJG8M4hBouIZq0mDUHZ+zjOx044J3nGRskwTb4Y= github.com/cosmos/gorocksdb v1.2.0/go.mod h1:aaKvKItm514hKfNJpUJXnnOWeBnk2GL4+Qw9NHizILw= github.com/cosmos/iavl v0.19.4 h1:t82sN+Y0WeqxDLJRSpNd8YFX5URIrT+p8n6oJbJ2Dok= @@ -453,8 +453,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.2.0 h1:y8Yozv7SZtlU//QXbez github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.6.0 h1:SXk3ABtQYDT/OH8jAyvEOQ58mgawq5C4o/4/89qN2ZU= -github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= +github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ= +github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= @@ -962,8 +962,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -1093,8 +1093,8 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1201,13 +1201,13 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220517195934-5e4e11fc645e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1217,8 +1217,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1315,8 +1315,8 @@ google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz513 google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.102.0 h1:JxJl2qQ85fRMPNvlZY/enexbxpCjLwGhZUtgfGeQ51I= -google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo= +google.golang.org/api v0.103.0 h1:9yuVqlu2JCvcLg9p8S3fcFLZij8EPSyvODIY1rkMizQ= +google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1369,8 +1369,8 @@ google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 h1:a2S6M0+660BgMNl++4JPlcAO/CjkqYItDEZwkoDQK7c= -google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20230202175211-008b39050e57 h1:vArvWooPH749rNHpBGgVl+U9B9dATjiEhJzcWGlovNs= +google.golang.org/genproto v0.0.0-20230202175211-008b39050e57/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY= google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= diff --git a/x/interchainquery/keeper/queries.go b/x/interchainquery/keeper/queries.go index ceaebeba45..6989e480c0 100644 --- a/x/interchainquery/keeper/queries.go +++ b/x/interchainquery/keeper/queries.go @@ -3,8 +3,13 @@ package keeper import ( "fmt" - "github.com/cosmos/cosmos-sdk/store/prefix" + sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + + errorsmod "cosmossdk.io/errors" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/store/prefix" "github.com/tendermint/tendermint/crypto" "github.com/Stride-Labs/stride/v6/x/interchainquery/types" @@ -80,3 +85,38 @@ func (k Keeper) AllQueries(ctx sdk.Context) []types.Query { }) return queries } + +// Helper function to unmarshal a Balance query response across SDK versions +// Before SDK v46, the query response returned a sdk.Coin type. SDK v46 returns an int type +// https://github.com/cosmos/cosmos-sdk/pull/9832 +func UnmarshalAmountFromBalanceQuery(cdc codec.BinaryCodec, queryResponseBz []byte) (amount sdkmath.Int, err error) { + // An nil should not be possible, exit immediately if it occurs + if queryResponseBz == nil { + return sdkmath.Int{}, errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, "query response is nil") + } + + // If the query response is empty, that means the account was never registed (and thus has a 0 balance) + if len(queryResponseBz) == 0 { + return sdkmath.ZeroInt(), nil + } + + // First attempt to unmarshal as an Int (for SDK v46+) + // If the result was serialized as a `Coin` type, it should contain a string (representing the denom) + // which will cause the unmarshalling to throw an error + intError := amount.Unmarshal(queryResponseBz) + if intError == nil { + return amount, nil + } + + // If the Int unmarshaling was unsuccessful, attempt again using a Coin type (for SDK v45 and below) + // If successful, return the amount field from the coin (if the coin is not nil) + var coin sdk.Coin + coinError := cdc.Unmarshal(queryResponseBz, &coin) + if coinError == nil { + return coin.Amount, nil + } + + // If it failed unmarshaling with either data structure, return an error with the failure messages combined + return sdkmath.Int{}, errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, + "unable to unmarshal balance query response %v as sdkmath.Int (err: %s) or sdk.Coin (err: %s)", queryResponseBz, intError.Error(), coinError.Error()) +} diff --git a/x/interchainquery/keeper/queries_test.go b/x/interchainquery/keeper/queries_test.go new file mode 100644 index 0000000000..dd2cb2f411 --- /dev/null +++ b/x/interchainquery/keeper/queries_test.go @@ -0,0 +1,124 @@ +package keeper_test + +import ( + "testing" + + sdkmath "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" + + "github.com/Stride-Labs/stride/v6/x/interchainquery/keeper" + "github.com/Stride-Labs/stride/v6/x/interchainquery/types" +) + +func TestUnmarshalAmountFromBalanceQuery(t *testing.T) { + type InputType int64 + const ( + rawBytes InputType = iota + coinType + intType + ) + + testCases := []struct { + name string + inputType InputType + raw []byte + coin sdk.Coin + integer sdkmath.Int + expectedAmount sdkmath.Int + expectedError string + }{ + { + name: "full_coin", + inputType: coinType, + coin: sdk.Coin{Denom: "denom", Amount: sdkmath.NewInt(50)}, + expectedAmount: sdkmath.NewInt(50), + }, + { + name: "coin_no_denom", + inputType: coinType, + coin: sdk.Coin{Amount: sdkmath.NewInt(60)}, + expectedAmount: sdkmath.NewInt(60), + }, + { + name: "coin_no_amount", + inputType: coinType, + coin: sdk.Coin{Denom: "denom"}, + expectedAmount: sdkmath.NewInt(0), + }, + { + name: "zero_coin", + inputType: coinType, + coin: sdk.Coin{Amount: sdkmath.NewInt(0)}, + expectedAmount: sdkmath.NewInt(0), + }, + { + name: "empty_coin", + inputType: coinType, + coin: sdk.Coin{}, + expectedAmount: sdkmath.NewInt(0), + }, + { + name: "positive_int", + inputType: intType, + integer: sdkmath.NewInt(20), + expectedAmount: sdkmath.NewInt(20), + }, + { + name: "zero_int", + inputType: intType, + integer: sdkmath.NewInt(0), + expectedAmount: sdkmath.NewInt(0), + }, + { + name: "empty_int", + inputType: intType, + integer: sdkmath.Int{}, + expectedAmount: sdkmath.NewInt(0), + }, + { + name: "empty_bytes", + inputType: rawBytes, + raw: []byte{}, + expectedAmount: sdkmath.NewInt(0), + }, + { + name: "invalid_bytes", + inputType: rawBytes, + raw: []byte{1, 2}, + expectedError: "unable to unmarshal balance query response", + }, + { + name: "nil_bytes", + inputType: rawBytes, + raw: nil, + expectedError: "query response is nil", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var args []byte + var err error + switch tc.inputType { + case rawBytes: + args = tc.raw + case coinType: + args, err = tc.coin.Marshal() + case intType: + args, err = tc.integer.Marshal() + } + require.NoError(t, err) + + if tc.expectedError == "" { + actualAmount, err := keeper.UnmarshalAmountFromBalanceQuery(types.ModuleCdc, args) + require.NoError(t, err) + require.Equal(t, tc.expectedAmount.Int64(), actualAmount.Int64()) + } else { + _, err := keeper.UnmarshalAmountFromBalanceQuery(types.ModuleCdc, args) + require.ErrorContains(t, err, tc.expectedError) + } + }) + } +} \ No newline at end of file diff --git a/x/interchainquery/types/keys.go b/x/interchainquery/types/keys.go index 8d2562b3c3..4c43e4cc26 100644 --- a/x/interchainquery/types/keys.go +++ b/x/interchainquery/types/keys.go @@ -26,8 +26,10 @@ const ( // new chain const ( + // The staking store is key'd by the validator's address STAKING_STORE_QUERY_WITH_PROOF = "store/staking/key" - BANK_STORE_QUERY_WITH_PROOF = "store/bank/key" + // The bank store is key'd by the account address + BANK_STORE_QUERY_WITH_PROOF = "store/bank/key" ) var ( diff --git a/x/stakeibc/keeper/hooks.go b/x/stakeibc/keeper/hooks.go index aa2af86e0b..377d630c5c 100644 --- a/x/stakeibc/keeper/hooks.go +++ b/x/stakeibc/keeper/hooks.go @@ -7,8 +7,6 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" "github.com/spf13/cast" - authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" - "github.com/Stride-Labs/stride/v6/utils" epochstypes "github.com/Stride-Labs/stride/v6/x/epochs/types" recordstypes "github.com/Stride-Labs/stride/v6/x/records/types" @@ -71,12 +69,7 @@ func (k Keeper) BeforeEpochStart(ctx sdk.Context, epochInfo epochstypes.EpochInf } } if epochInfo.Identifier == epochstypes.MINT_EPOCH { - err := k.AllocateHostZoneReward(ctx) - if err != nil { - k.Logger(ctx).Error(fmt.Sprintf("Unable to allocate host zone reward, err: %s", err.Error())) - return - } - + k.AllocateHostZoneReward(ctx) } } @@ -233,40 +226,3 @@ func (k Keeper) ReinvestRewards(ctx sdk.Context) { } } } - -func (k Keeper) AllocateHostZoneReward(ctx sdk.Context) error { - k.Logger(ctx).Info("Allocate host zone reward to delegator") - - rewardCollectorAddress := k.accountKeeper.GetModuleAccount(ctx, types.RewardCollectorName).GetAddress() - rewardedTokens := k.bankKeeper.GetAllBalances(ctx, rewardCollectorAddress) - if rewardedTokens.IsEqual(sdk.Coins{}) { - return nil - } - - msgSvr := NewMsgServerImpl(k) - for _, token := range rewardedTokens { - // get hostzone by reward token (in ibc denom format) - hz, err := k.GetHostZoneFromIBCDenom(ctx, token.Denom) - if err != nil { - k.Logger(ctx).Info("Can't get host zone from ibc token %s", token.Denom) - continue - } - - // liquid stake all tokens - msg := types.NewMsgLiquidStake(rewardCollectorAddress.String(), token.Amount, hz.HostDenom) - _, err = msgSvr.LiquidStake(ctx, msg) - if err != nil { - k.Logger(ctx).Info("Can't liquid stake %s for hostzone %s", token.String(), hz.ChainId) - continue - } - } - // After liquid stake all tokens, reward collector receive stTokens - // Send all stTokens to fee collector to distribute to delegator later - stTokens := k.bankKeeper.GetAllBalances(ctx, rewardCollectorAddress) - err := k.bankKeeper.SendCoinsFromModuleToModule(ctx, types.RewardCollectorName, authtypes.FeeCollectorName, stTokens) - if err != nil { - k.Logger(ctx).Info("Can't send coins from module %s to module %s", types.RewardCollectorName, authtypes.FeeCollectorName) - return err - } - return nil -} diff --git a/x/stakeibc/keeper/host_zone.go b/x/stakeibc/keeper/host_zone.go index e35d0d4cdd..d57a71ccda 100644 --- a/x/stakeibc/keeper/host_zone.go +++ b/x/stakeibc/keeper/host_zone.go @@ -226,6 +226,16 @@ func (k Keeper) GetHostZoneFromIBCDenom(ctx sdk.Context, denom string) (*types.H return nil, errorsmod.Wrapf(sdkerrors.ErrUnknownRequest, "No HostZone for %s found", denom) } +// Validate whether a denom is a supported liquid staking token +func (k Keeper) CheckIsStToken(ctx sdk.Context, denom string) bool { + for _, hostZone := range k.GetAllHostZone(ctx) { + if types.StAssetDenomFromHostZoneDenom(hostZone.HostDenom) == denom { + return true + } + } + return false +} + // IterateHostZones iterates zones func (k Keeper) IterateHostZones(ctx sdk.Context, fn func(ctx sdk.Context, index int64, zoneInfo types.HostZone) error) { store := prefix.NewStore(ctx.KVStore(k.storeKey), types.KeyPrefix(types.HostZoneKey)) diff --git a/x/stakeibc/keeper/icacallbacks_reinvest.go b/x/stakeibc/keeper/icacallbacks_reinvest.go index 4c0f27cbf3..54f850cab0 100644 --- a/x/stakeibc/keeper/icacallbacks_reinvest.go +++ b/x/stakeibc/keeper/icacallbacks_reinvest.go @@ -3,9 +3,14 @@ package keeper import ( "fmt" + "github.com/cosmos/cosmos-sdk/types/bech32" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/Stride-Labs/stride/v6/utils" epochtypes "github.com/Stride-Labs/stride/v6/x/epochs/types" icacallbackstypes "github.com/Stride-Labs/stride/v6/x/icacallbacks/types" + icqtypes "github.com/Stride-Labs/stride/v6/x/interchainquery/types" recordstypes "github.com/Stride-Labs/stride/v6/x/records/types" "github.com/Stride-Labs/stride/v6/x/stakeibc/types" @@ -38,6 +43,7 @@ func (k Keeper) UnmarshalReinvestCallbackArgs(ctx sdk.Context, reinvestCallback // ICA Callback after reinvestment // If successful: // * Creates a new DepositRecord with the reinvestment amount +// * Issues an ICQ to query the rewards balance // If timeout/failure: // * Does nothing func ReinvestCallback(k Keeper, ctx sdk.Context, packet channeltypes.Packet, ackResponse *icacallbackstypes.AcknowledgementResponse, args []byte) error { @@ -49,6 +55,12 @@ func ReinvestCallback(k Keeper, ctx sdk.Context, packet channeltypes.Packet, ack chainId := reinvestCallback.HostZoneId k.Logger(ctx).Info(utils.LogICACallbackWithHostZone(chainId, ICACallbackID_Reinvest, "Starting reinvest callback")) + // Grab the associated host zone + hostZone, found := k.GetHostZone(ctx, chainId) + if !found { + return errorsmod.Wrapf(types.ErrHostZoneNotFound, "host zone %s not found", chainId) + } + // Check for timeout (ack nil) // No action is necessary on a timeout if ackResponse.Status == icacallbackstypes.AckResponseStatus_TIMEOUT { @@ -86,5 +98,34 @@ func ReinvestCallback(k Keeper, ctx sdk.Context, packet channeltypes.Packet, ack } k.RecordsKeeper.AppendDepositRecord(ctx, record) - return nil + // Encode the fee account address for the query request + // The query request consists of the fee account address and denom + feeAccount := hostZone.FeeAccount + if feeAccount == nil || feeAccount.Address == "" { + return errorsmod.Wrapf(types.ErrICAAccountNotFound, "no fee account found for %s", chainId) + } + _, feeAddressBz, err := bech32.DecodeAndConvert(feeAccount.Address) + if err != nil { + return errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, "invalid fee account address, could not decode (%s)", err.Error()) + } + queryData := append(banktypes.CreateAccountBalancesPrefix(feeAddressBz), []byte(hostZone.HostDenom)...) + + // The query should timeout before the next epoch + timeout, err := k.GetICATimeoutNanos(ctx, epochtypes.STRIDE_EPOCH) + if err != nil { + return errorsmod.Wrapf(err, "Failed to get ICATimeout from %s epoch", epochtypes.STRIDE_EPOCH) + } + + // Submit an ICQ for the rewards balance in the fee account + k.Logger(ctx).Info(utils.LogICACallbackWithHostZone(chainId, ICACallbackID_Reinvest, "Submitting ICQ for fee account balance")) + return k.InterchainQueryKeeper.MakeRequest( + ctx, + types.ModuleName, + ICQCallbackID_FeeBalance, + chainId, + hostZone.ConnectionId, + icqtypes.BANK_STORE_QUERY_WITH_PROOF, + queryData, + timeout, + ) } diff --git a/x/stakeibc/keeper/icacallbacks_reinvest_test.go b/x/stakeibc/keeper/icacallbacks_reinvest_test.go index bece32db6c..7c9b36a98d 100644 --- a/x/stakeibc/keeper/icacallbacks_reinvest_test.go +++ b/x/stakeibc/keeper/icacallbacks_reinvest_test.go @@ -4,22 +4,27 @@ import ( sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" channeltypes "github.com/cosmos/ibc-go/v5/modules/core/04-channel/types" + ibctesting "github.com/cosmos/ibc-go/v5/testing" _ "github.com/stretchr/testify/suite" + "github.com/Stride-Labs/stride/v6/app/apptesting" epochtypes "github.com/Stride-Labs/stride/v6/x/epochs/types" + icqtypes "github.com/Stride-Labs/stride/v6/x/interchainquery/types" icacallbacktypes "github.com/Stride-Labs/stride/v6/x/icacallbacks/types" recordtypes "github.com/Stride-Labs/stride/v6/x/records/types" stakeibckeeper "github.com/Stride-Labs/stride/v6/x/stakeibc/keeper" "github.com/Stride-Labs/stride/v6/x/stakeibc/types" - stakeibc "github.com/Stride-Labs/stride/v6/x/stakeibc/types" + stakeibctypes "github.com/Stride-Labs/stride/v6/x/stakeibc/types" ) type ReinvestCallbackState struct { - reinvestAmt sdkmath.Int - callbackArgs types.ReinvestCallback - depositRecord recordtypes.DepositRecord + hostZone stakeibctypes.HostZone + reinvestAmt sdkmath.Int + callbackArgs types.ReinvestCallback + depositRecord recordtypes.DepositRecord + icaTimeoutTime int64 } type ReinvestCallbackArgs struct { @@ -35,12 +40,21 @@ type ReinvestCallbackTestCase struct { func (s *KeeperTestSuite) SetupReinvestCallback() ReinvestCallbackTestCase { reinvestAmt := sdkmath.NewInt(1_000) + feeAddress := apptesting.CreateRandomAccounts(1)[0].String() // must be valid bech32 address - hostZone := stakeibc.HostZone{ + epochEndTime := uint64(100) + buffer := uint64(10) + icaTimeoutTime := int64(90) + + hostZone := stakeibctypes.HostZone{ ChainId: HostChainId, HostDenom: Atom, IbcDenom: IbcAtom, RedemptionRate: sdk.NewDec(1.0), + ConnectionId: ibctesting.FirstConnectionID, + FeeAccount: &stakeibctypes.ICAAccount{ + Address: feeAddress, + }, } expectedNewDepositRecord := recordtypes.DepositRecord{ Id: 0, @@ -50,13 +64,19 @@ func (s *KeeperTestSuite) SetupReinvestCallback() ReinvestCallbackTestCase { Status: recordtypes.DepositRecord_DELEGATION_QUEUE, Source: recordtypes.DepositRecord_WITHDRAWAL_ICA, } - epochTracker := stakeibc.EpochTracker{ - EpochIdentifier: epochtypes.STRIDE_EPOCH, - EpochNumber: 1, + epochTracker := stakeibctypes.EpochTracker{ + EpochIdentifier: epochtypes.STRIDE_EPOCH, + EpochNumber: 1, + NextEpochStartTime: epochEndTime, + Duration: epochEndTime, } s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, epochTracker) + params := s.App.StakeibcKeeper.GetParams(s.Ctx) + params.BufferSize = buffer + s.App.StakeibcKeeper.SetParams(s.Ctx, params) + packet := channeltypes.Packet{} ackResponse := icacallbacktypes.AcknowledgementResponse{Status: icacallbacktypes.AckResponseStatus_SUCCESS} callbackArgs := types.ReinvestCallback{ @@ -68,9 +88,11 @@ func (s *KeeperTestSuite) SetupReinvestCallback() ReinvestCallbackTestCase { return ReinvestCallbackTestCase{ initialState: ReinvestCallbackState{ - reinvestAmt: reinvestAmt, - callbackArgs: callbackArgs, - depositRecord: expectedNewDepositRecord, + hostZone: hostZone, + reinvestAmt: reinvestAmt, + callbackArgs: callbackArgs, + depositRecord: expectedNewDepositRecord, + icaTimeoutTime: icaTimeoutTime, }, validArgs: ReinvestCallbackArgs{ packet: packet, @@ -101,6 +123,17 @@ func (s *KeeperTestSuite) TestReinvestCallback_Successful() { s.Require().Equal(expectedRecord.Status, record.Status, "deposit record Status") s.Require().Equal(expectedRecord.Source, record.Source, "deposit record Source") s.Require().Equal(int64(expectedRecord.DepositEpochNumber), int64(record.DepositEpochNumber), "deposit record DepositEpochNumber") + + // Confirm an interchain query was submitted for the fee account balance + allQueries := s.App.InterchainqueryKeeper.AllQueries(s.Ctx) + s.Require().Len(allQueries, 1, "should be 1 query submitted") + + query := allQueries[0] + s.Require().Equal(stakeibckeeper.ICQCallbackID_FeeBalance, query.CallbackId, "query callback ID") + s.Require().Equal(HostChainId, query.ChainId, "query chain ID") + s.Require().Equal(ibctesting.FirstConnectionID, query.ConnectionId, "query connection ID") + s.Require().Equal(icqtypes.BANK_STORE_QUERY_WITH_PROOF, query.QueryType, "query type") + s.Require().Equal(tc.initialState.icaTimeoutTime, int64(query.Ttl), "query timeout") } func (s *KeeperTestSuite) checkReinvestStateIfCallbackFailed(tc ReinvestCallbackTestCase) { @@ -142,7 +175,40 @@ func (s *KeeperTestSuite) TestReinvestCallback_WrongCallbackArgs() { err := stakeibckeeper.ReinvestCallback(s.App.StakeibcKeeper, s.Ctx, invalidArgs.packet, invalidArgs.ackResponse, invalidCallbackArgs) s.Require().EqualError(err, "Unable to unmarshal reinvest callback args: unexpected EOF: unable to unmarshal data structure") - s.checkReinvestStateIfCallbackFailed(tc) +} + +func (s *KeeperTestSuite) TestReinvestCallback_HostZoneNotFound() { + tc := s.SetupReinvestCallback() + + // Remove the host zone + s.App.StakeibcKeeper.RemoveHostZone(s.Ctx, HostChainId) + + err := stakeibckeeper.ReinvestCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.packet, tc.validArgs.ackResponse, tc.validArgs.args) + s.Require().ErrorContains(err, "host zone GAIA not found: host zone not found") +} + +func (s *KeeperTestSuite) TestReinvestCallback_NoFeeAccount() { + tc := s.SetupReinvestCallback() + + // Remove the fee account + badHostZone := tc.initialState.hostZone + badHostZone.FeeAccount = nil + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + err := stakeibckeeper.ReinvestCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.packet, tc.validArgs.ackResponse, tc.validArgs.args) + s.Require().EqualError(err, "no fee account found for GAIA: ICA acccount not found on host zone") +} + +func (s *KeeperTestSuite) TestReinvestCallback_InvalidFeeAccountAddress() { + tc := s.SetupReinvestCallback() + + // Remove the fee account + badHostZone := tc.initialState.hostZone + badHostZone.FeeAccount.Address = "invalid_fee_account" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + err := stakeibckeeper.ReinvestCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.packet, tc.validArgs.ackResponse, tc.validArgs.args) + s.Require().ErrorContains(err, "invalid fee account address, could not decode") } func (s *KeeperTestSuite) TestReinvestCallback_MissingEpoch() { @@ -154,5 +220,17 @@ func (s *KeeperTestSuite) TestReinvestCallback_MissingEpoch() { err := stakeibckeeper.ReinvestCallback(s.App.StakeibcKeeper, s.Ctx, invalidArgs.packet, invalidArgs.ackResponse, invalidArgs.args) s.Require().ErrorContains(err, "no number for epoch (stride_epoch)") - s.checkReinvestStateIfCallbackFailed(tc) } + +func (s *KeeperTestSuite) TestReinvestCallback_FailedToSubmitQuery() { + tc := s.SetupReinvestCallback() + invalidArgs := tc.validArgs + + // Remove the connection ID from the host zone so that the query submission fails + badHostZone := tc.initialState.hostZone + badHostZone.ConnectionId = "" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + err := stakeibckeeper.ReinvestCallback(s.App.StakeibcKeeper, s.Ctx, invalidArgs.packet, invalidArgs.ackResponse, invalidArgs.args) + s.Require().EqualError(err, "[ICQ Validation Check] Failed! connection id cannot be empty: invalid request") +} \ No newline at end of file diff --git a/x/stakeibc/keeper/icqcallbacks.go b/x/stakeibc/keeper/icqcallbacks.go index d1601d625b..6bd79f0ff3 100644 --- a/x/stakeibc/keeper/icqcallbacks.go +++ b/x/stakeibc/keeper/icqcallbacks.go @@ -8,6 +8,7 @@ import ( const ( ICQCallbackID_WithdrawalBalance = "withdrawalbalance" + ICQCallbackID_FeeBalance = "feebalance" ICQCallbackID_Delegation = "delegation" ICQCallbackID_Validator = "validator" ) @@ -43,6 +44,7 @@ func (c ICQCallbacks) AddICQCallback(id string, fn interface{}) icqtypes.QueryCa func (c ICQCallbacks) RegisterICQCallbacks() icqtypes.QueryCallbacks { return c. AddICQCallback(ICQCallbackID_WithdrawalBalance, ICQCallback(WithdrawalBalanceCallback)). + AddICQCallback(ICQCallbackID_FeeBalance, ICQCallback(FeeBalanceCallback)). AddICQCallback(ICQCallbackID_Delegation, ICQCallback(DelegatorSharesCallback)). AddICQCallback(ICQCallbackID_Validator, ICQCallback(ValidatorExchangeRateCallback)) -} +} \ No newline at end of file diff --git a/x/stakeibc/keeper/icqcallbacks_fee_balance.go b/x/stakeibc/keeper/icqcallbacks_fee_balance.go new file mode 100644 index 0000000000..329ddb8c85 --- /dev/null +++ b/x/stakeibc/keeper/icqcallbacks_fee_balance.go @@ -0,0 +1,92 @@ +package keeper + +import ( + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + + errorsmod "cosmossdk.io/errors" + ibctypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" + transfertypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" + clienttypes "github.com/cosmos/ibc-go/v5/modules/core/02-client/types" + channeltypes "github.com/cosmos/ibc-go/v5/modules/core/04-channel/types" + + "github.com/Stride-Labs/stride/v6/utils" + epochtypes "github.com/Stride-Labs/stride/v6/x/epochs/types" + icqkeeper "github.com/Stride-Labs/stride/v6/x/interchainquery/keeper" + icqtypes "github.com/Stride-Labs/stride/v6/x/interchainquery/types" + "github.com/Stride-Labs/stride/v6/x/stakeibc/types" +) + +// FeeBalanceCallback is a callback handler for FeeBalnce queries. +// The query response will return the fee account balance +// If the balance is non-zero, an ICA MsgTransfer is initated to the RewardsCollector account +// Note: for now, to get proofs in your ICQs, you need to query the entire store on the host zone! e.g. "store/bank/key" +func FeeBalanceCallback(k Keeper, ctx sdk.Context, args []byte, query icqtypes.Query) error { + k.Logger(ctx).Info(utils.LogICQCallbackWithHostZone(query.ChainId, ICQCallbackID_FeeBalance, + "Starting fee balance callback, QueryId: %vs, QueryType: %s, Connection: %s", query.Id, query.QueryType, query.ConnectionId)) + + // Confirm host exists + chainId := query.ChainId + hostZone, found := k.GetHostZone(ctx, chainId) + if !found { + return errorsmod.Wrapf(types.ErrHostZoneNotFound, "no registered zone for queried chain ID (%s)", chainId) + } + + // Unmarshal the query response args to determine the balance + feeBalanceAmount, err := icqkeeper.UnmarshalAmountFromBalanceQuery(k.cdc, args) + if err != nil { + return errorsmod.Wrap(err, "unable to determine balance from query response") + } + k.Logger(ctx).Info(utils.LogICQCallbackWithHostZone(chainId, ICQCallbackID_FeeBalance, + "Query response - Fee Balance: %v %s", feeBalanceAmount, hostZone.HostDenom)) + + // Confirm the balance is greater than zero + if feeBalanceAmount.LTE(sdkmath.ZeroInt()) { + k.Logger(ctx).Info(utils.LogICQCallbackWithHostZone(chainId, ICQCallbackID_FeeBalance, + "No balance to transfer for address: %v, balance: %v", hostZone.FeeAccount.GetAddress(), feeBalanceAmount)) + return nil + } + + // Confirm the fee account has been initiated + feeAccount := hostZone.FeeAccount + if feeAccount == nil || feeAccount.Address == "" { + return errorsmod.Wrapf(types.ErrICAAccountNotFound, "no fee account found for %s", chainId) + } + + // The ICA and transfer should both timeout before the end of the epoch + timeout, err := k.GetICATimeoutNanos(ctx, epochtypes.STRIDE_EPOCH) + if err != nil { + return errorsmod.Wrapf(err, "Failed to get ICATimeout from %s epoch", epochtypes.STRIDE_EPOCH) + } + + // get counterparty chain's transfer channel + transferChannel, found := k.IBCKeeper.ChannelKeeper.GetChannel(ctx, transfertypes.PortID, hostZone.TransferChannelId) + if !found { + return errorsmod.Wrapf(channeltypes.ErrChannelNotFound, "transfer channel %s not found", hostZone.TransferChannelId) + } + counterpartyChannelId := transferChannel.Counterparty.ChannelId + + // Prepare a MsgTransfer from the fee account to the rewards collector account + rewardsCoin := sdk.NewCoin(hostZone.HostDenom, feeBalanceAmount) + rewardsCollectorAddress := k.accountKeeper.GetModuleAccount(ctx, types.RewardCollectorName).GetAddress() + transferMsg := ibctypes.NewMsgTransfer( + transfertypes.PortID, + counterpartyChannelId, + rewardsCoin, + feeAccount.Address, + rewardsCollectorAddress.String(), + clienttypes.Height{}, + timeout, + ) + + msgs := []sdk.Msg{transferMsg} + k.Logger(ctx).Info(utils.LogICQCallbackWithHostZone(chainId, ICQCallbackID_FeeBalance, + "Preparing MsgTransfer of %v from the fee account to the rewards collector module account (for commission)", rewardsCoin.String())) + + // Send the transaction through SubmitTx + if _, err := k.SubmitTxsStrideEpoch(ctx, hostZone.ConnectionId, msgs, *feeAccount, ICACallbackID_Reinvest, nil); err != nil { + return errorsmod.Wrapf(types.ErrICATxFailed, "Failed to SubmitTxs, Messages: %v, err: %s", msgs, err.Error()) + } + + return nil +} \ No newline at end of file diff --git a/x/stakeibc/keeper/icqcallbacks_fee_balance_test.go b/x/stakeibc/keeper/icqcallbacks_fee_balance_test.go new file mode 100644 index 0000000000..ccf448174c --- /dev/null +++ b/x/stakeibc/keeper/icqcallbacks_fee_balance_test.go @@ -0,0 +1,226 @@ +package keeper_test + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + ibctesting "github.com/cosmos/ibc-go/v5/testing" + + icatypes "github.com/cosmos/ibc-go/v5/modules/apps/27-interchain-accounts/types" + + epochtypes "github.com/Stride-Labs/stride/v6/x/epochs/types" + icqtypes "github.com/Stride-Labs/stride/v6/x/interchainquery/types" + stakeibckeeper "github.com/Stride-Labs/stride/v6/x/stakeibc/keeper" + stakeibctypes "github.com/Stride-Labs/stride/v6/x/stakeibc/types" +) + +type FeeBalanceICQCallbackState struct { + hostZone stakeibctypes.HostZone + feeChannel Channel + feeBalance int64 + startICASequence uint64 +} + +type FeeBalanceICQCallbackArgs struct { + query icqtypes.Query + callbackArgs []byte +} + +type FeeBalanceICQCallbackTestCase struct { + initialState FeeBalanceICQCallbackState + validArgs FeeBalanceICQCallbackArgs +} + +func (s *KeeperTestSuite) SetupFeeBalanceCallbackTest() FeeBalanceICQCallbackTestCase { + feeAccountOwner := fmt.Sprintf("%s.%s", HostChainId, "FEE") + feeChannelId := s.CreateICAChannel(feeAccountOwner) + feeAddress := s.IcaAddresses[feeAccountOwner] + + hostZone := stakeibctypes.HostZone{ + ChainId: HostChainId, + HostDenom: Atom, + ConnectionId: ibctesting.FirstConnectionID, + FeeAccount: &stakeibctypes.ICAAccount{ + Address: feeAddress, + Target: stakeibctypes.ICAAccountType_FEE, + }, + TransferChannelId: ibctesting.FirstChannelID, + } + + strideEpochTracker := stakeibctypes.EpochTracker{ + EpochIdentifier: epochtypes.STRIDE_EPOCH, + EpochNumber: 1, + NextEpochStartTime: uint64(s.Coordinator.CurrentTime.UnixNano() + 30_000_000_000), // dictates timeouts + } + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, strideEpochTracker) + + // Get the next sequence number to confirm if an ICA was sent + feePortId := icatypes.PortPrefix + feeAccountOwner + startSequence, found := s.App.IBCKeeper.ChannelKeeper.GetNextSequenceSend(s.Ctx, feePortId, feeChannelId) + s.Require().True(found, "sequence number not found before ICA") + + feeBalance := int64(100) + queryResponse := s.CreateBalanceQueryResponse(feeBalance, Atom) + + return FeeBalanceICQCallbackTestCase{ + initialState: FeeBalanceICQCallbackState{ + hostZone: hostZone, + feeChannel: Channel{ + PortID: feePortId, + ChannelID: feeChannelId, + }, + feeBalance: feeBalance, + startICASequence: startSequence, + }, + validArgs: FeeBalanceICQCallbackArgs{ + query: icqtypes.Query{ + Id: "0", + ChainId: HostChainId, + }, + callbackArgs: queryResponse, + }, + } +} + +// Helper function to check that no ICA was submitted in the case of the function exiting prematurely +func (s *KeeperTestSuite) CheckNoICASubmitted(tc FeeBalanceICQCallbackTestCase) { + feeChannel := tc.initialState.feeChannel + feePortId := feeChannel.PortID + feeChannelId := feeChannel.ChannelID + + // The sequence number should not have incremented + expectedSequence := tc.initialState.startICASequence + endSequence, found := s.App.IBCKeeper.ChannelKeeper.GetNextSequenceSend(s.Ctx, feePortId, feeChannelId) + s.Require().True(found, "sequence number not found after ICA") + s.Require().Equal(expectedSequence, endSequence, "sequence number after ICA") +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_Successful() { + tc := s.SetupFeeBalanceCallbackTest() + + // Get the sequence number before the ICA is submitted to confirm it incremented + feeChannel := tc.initialState.feeChannel + feePortId := feeChannel.PortID + feeChannelId := feeChannel.ChannelID + + // Call the ICQ callback + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.callbackArgs, tc.validArgs.query) + s.Require().NoError(err) + + // Confirm the sequence number was incremented + expectedSequence := tc.initialState.startICASequence + 1 + actualSequence, found := s.App.IBCKeeper.ChannelKeeper.GetNextSequenceSend(s.Ctx, feePortId, feeChannelId) + s.Require().True(found, "sequence number not found after ICA") + s.Require().Equal(expectedSequence, actualSequence, "sequence number after ICA") +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_EmptyCallbackArgs() { + tc := s.SetupFeeBalanceCallbackTest() + + // Replace the query response an empty byte array (this happens when the account has not been registered yet) + emptyCallbackArgs := []byte{} + + // It should short circuit but not throw an error + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, emptyCallbackArgs, tc.validArgs.query) + s.Require().NoError(err) + + // No ICA should have been submitted + s.CheckNoICASubmitted(tc) +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_ZeroBalance() { + tc := s.SetupFeeBalanceCallbackTest() + + // Replace the query response with a coin that has a nil amount + tc.validArgs.callbackArgs = s.CreateBalanceQueryResponse(0, Atom) + + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.callbackArgs, tc.validArgs.query) + s.Require().NoError(err) + + // Confirm revinvestment callback was not created + s.CheckNoICASubmitted(tc) +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_ZeroBalanceImplied() { + tc := s.SetupFeeBalanceCallbackTest() + + // Replace the query response with a coin that has a nil amount + coin := sdk.Coin{} + coinBz := s.App.RecordsKeeper.Cdc.MustMarshal(&coin) + tc.validArgs.callbackArgs = coinBz + + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.callbackArgs, tc.validArgs.query) + s.Require().NoError(err) + + // Confirm revinvestment callback was not created + s.Require().Len(s.App.IcacallbacksKeeper.GetAllCallbackData(s.Ctx), 0, "number of callbacks found") +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_HostZoneNotFound() { + tc := s.SetupFeeBalanceCallbackTest() + + // Submit callback with incorrect host zone + invalidQuery := tc.validArgs.query + invalidQuery.ChainId = "fake_host_zone" + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.callbackArgs, invalidQuery) + s.Require().EqualError(err, "no registered zone for queried chain ID (fake_host_zone): host zone not found") +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_InvalidArgs() { + tc := s.SetupFeeBalanceCallbackTest() + + // Submit callback with invalid callback args (so that it can't unmarshal into a coin) + invalidArgs := []byte("random bytes") + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, invalidArgs, tc.validArgs.query) + + s.Require().ErrorContains(err, "unable to determine balance from query response") +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_NoFeeAccount() { + tc := s.SetupFeeBalanceCallbackTest() + + // Remove the fee account + badHostZone := tc.initialState.hostZone + badHostZone.FeeAccount = nil + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.callbackArgs, tc.validArgs.query) + s.Require().EqualError(err, "no fee account found for GAIA: ICA acccount not found on host zone") +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_FailedToCalculatedTimeout() { + tc := s.SetupFeeBalanceCallbackTest() + + // Remove the epoch tracker so that it cannot calculate the ICA timeout + s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.STRIDE_EPOCH) + + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.callbackArgs, tc.validArgs.query) + s.Require().ErrorContains(err, "Failed to get ICATimeout from stride_epoch epoch:") +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_NoTransferChannel() { + tc := s.SetupFeeBalanceCallbackTest() + + // Set an invalid transfer channel so that the counterparty channel cannot be found + badHostZone := tc.initialState.hostZone + badHostZone.TransferChannelId = "channel-X" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.callbackArgs, tc.validArgs.query) + s.Require().EqualError(err, "transfer channel channel-X not found: channel not found") +} + +func (s *KeeperTestSuite) TestFeeBalanceCallback_FailedSubmitTx() { + tc := s.SetupFeeBalanceCallbackTest() + + // Remove connectionId from host zone so the ICA tx fails + badHostZone := tc.initialState.hostZone + badHostZone.ConnectionId = "connection-X" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + err := stakeibckeeper.FeeBalanceCallback(s.App.StakeibcKeeper, s.Ctx, tc.validArgs.callbackArgs, tc.validArgs.query) + s.Require().ErrorContains(err, "Failed to SubmitTxs") + s.Require().ErrorContains(err, "invalid connection id, connection-X not found") +} \ No newline at end of file diff --git a/x/stakeibc/keeper/icqcallbacks_withdrawal_balance.go b/x/stakeibc/keeper/icqcallbacks_withdrawal_balance.go index 1482d5d972..f18c6f5d4e 100644 --- a/x/stakeibc/keeper/icqcallbacks_withdrawal_balance.go +++ b/x/stakeibc/keeper/icqcallbacks_withdrawal_balance.go @@ -4,7 +4,6 @@ import ( "fmt" sdkmath "cosmossdk.io/math" - "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" errorsmod "cosmossdk.io/errors" @@ -12,11 +11,8 @@ import ( banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" "github.com/spf13/cast" - ibctransfertypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" - ibctypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" - clienttypes "github.com/cosmos/ibc-go/v5/modules/core/02-client/types" - "github.com/Stride-Labs/stride/v6/utils" + icqkeeper "github.com/Stride-Labs/stride/v6/x/interchainquery/keeper" icqtypes "github.com/Stride-Labs/stride/v6/x/interchainquery/types" "github.com/Stride-Labs/stride/v6/x/stakeibc/types" ) @@ -27,7 +23,6 @@ import ( // to the delegation account (for reinvestment) and fee account (for commission) // Note: for now, to get proofs in your ICQs, you need to query the entire store on the host zone! e.g. "store/bank/key" func WithdrawalBalanceCallback(k Keeper, ctx sdk.Context, args []byte, query icqtypes.Query) error { - fmt.Println("WithdrawalBalanceCallback") k.Logger(ctx).Info(utils.LogICQCallbackWithHostZone(query.ChainId, ICQCallbackID_WithdrawalBalance, "Starting withdrawal balance callback, QueryId: %vs, QueryType: %s, Connection: %s", query.Id, query.QueryType, query.ConnectionId)) @@ -39,7 +34,7 @@ func WithdrawalBalanceCallback(k Keeper, ctx sdk.Context, args []byte, query icq } // Unmarshal the query response args to determine the balance - withdrawalBalanceAmount, err := UnmarshalAmountFromBalanceQuery(k.cdc, args) + withdrawalBalanceAmount, err := icqkeeper.UnmarshalAmountFromBalanceQuery(k.cdc, args) if err != nil { return errorsmod.Wrap(err, "unable to determine balance from query response") } @@ -87,7 +82,7 @@ func WithdrawalBalanceCallback(k Keeper, ctx sdk.Context, args []byte, query icq // Safety check, balances should add to original amount if !feeAmount.Add(reinvestAmount).Equal(withdrawalBalanceAmount) { k.Logger(ctx).Error(fmt.Sprintf("Error with withdraw logic: %v, Fee Portion: %v, Reinvest Portion %v", withdrawalBalanceAmount, feeAmount, reinvestAmount)) - return errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "Failed to subdivide rewards to feeAccount and delegationAccount") + return errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "Failed to subdivide rewards to commission and delegationAccount") } // Prepare MsgSends from the withdrawal account @@ -96,14 +91,13 @@ func WithdrawalBalanceCallback(k Keeper, ctx sdk.Context, args []byte, query icq var msgs []sdk.Msg if feeCoin.Amount.GT(sdk.ZeroInt()) { - ibcTransferTimeoutNanos := k.GetParam(ctx, types.KeyIBCTransferTimeoutNanos) - timeoutTimestamp := uint64(ctx.BlockTime().UnixNano()) + ibcTransferTimeoutNanos - receiver := k.accountKeeper.GetModuleAccount(ctx, types.RewardCollectorName).GetAddress() - msg := ibctypes.NewMsgTransfer(ibctransfertypes.PortID, hostZone.TransferChannelId, feeCoin, withdrawalAccount.Address, receiver.String(), clienttypes.Height{}, timeoutTimestamp) - - msgs = append(msgs, msg) + msgs = append(msgs, &banktypes.MsgSend{ + FromAddress: withdrawalAccount.Address, + ToAddress: feeAccount.Address, + Amount: sdk.NewCoins(feeCoin), + }) k.Logger(ctx).Info(utils.LogICQCallbackWithHostZone(chainId, ICQCallbackID_WithdrawalBalance, - "Preparing MsgSends of %v from the withdrawal account to the distribution module account (for commission)", feeCoin.String())) + "Preparing MsgSends of %v from the withdrawal account to the fee account (for commission)", feeCoin.String())) } if reinvestCoin.Amount.GT(sdk.ZeroInt()) { msgs = append(msgs, &banktypes.MsgSend{ @@ -142,38 +136,3 @@ func WithdrawalBalanceCallback(k Keeper, ctx sdk.Context, args []byte, query icq return nil } - -// Helper function to unmarshal a Balance query response across SDK versions -// Before SDK v46, the query response returned a sdk.Coin type. SDK v46 returns an int type -// https://github.com/cosmos/cosmos-sdk/pull/9832 -func UnmarshalAmountFromBalanceQuery(cdc codec.BinaryCodec, queryResponseBz []byte) (amount sdkmath.Int, err error) { - // An nil should not be possible, exit immediately if it occurs - if queryResponseBz == nil { - return sdkmath.Int{}, errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, "query response is nil") - } - - // If the query response is empty, that means the account was never registed (and thus has a 0 balance) - if len(queryResponseBz) == 0 { - return sdkmath.ZeroInt(), nil - } - - // First attempt to unmarshal as an Int (for SDK v46+) - // If the result was serialized as a `Coin` type, it should contain a string (representing the denom) - // which will cause the unmarshalling to throw an error - intError := amount.Unmarshal(queryResponseBz) - if intError == nil { - return amount, nil - } - - // If the Int unmarshaling was unsuccessful, attempt again using a Coin type (for SDK v45 and below) - // If successful, return the amount field from the coin (if the coin is not nil) - var coin sdk.Coin - coinError := cdc.Unmarshal(queryResponseBz, &coin) - if coinError == nil { - return coin.Amount, nil - } - - // If it failed unmarshaling with either data structure, return an error with the failure messages combined - return sdkmath.Int{}, errorsmod.Wrapf(types.ErrUnmarshalFailure, - "unable to unmarshal balance query response %v as sdkmath.Int (err: %s) or sdk.Coin (err: %s)", queryResponseBz, intError.Error(), coinError.Error()) -} diff --git a/x/stakeibc/keeper/icqcallbacks_withdrawal_balance_test.go b/x/stakeibc/keeper/icqcallbacks_withdrawal_balance_test.go index 4e85b1fef0..2fd0a1e4d2 100644 --- a/x/stakeibc/keeper/icqcallbacks_withdrawal_balance_test.go +++ b/x/stakeibc/keeper/icqcallbacks_withdrawal_balance_test.go @@ -2,12 +2,10 @@ package keeper_test import ( "fmt" - "testing" sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" ibctesting "github.com/cosmos/ibc-go/v5/testing" - "github.com/stretchr/testify/require" icatypes "github.com/cosmos/ibc-go/v5/modules/apps/27-interchain-accounts/types" @@ -253,114 +251,3 @@ func (s *KeeperTestSuite) TestWithdrawalBalanceCallback_FailedSubmitTx() { s.Require().ErrorContains(err, "Failed to SubmitTxs") s.Require().ErrorContains(err, "invalid connection id, connection-X not found") } - -func TestUnmarshalAmountFromBalanceQuery(t *testing.T) { - type InputType int64 - const ( - rawBytes InputType = iota - coinType - intType - ) - - testCases := []struct { - name string - inputType InputType - raw []byte - coin sdk.Coin - integer sdkmath.Int - expectedAmount sdkmath.Int - expectedError string - }{ - { - name: "full_coin", - inputType: coinType, - coin: sdk.Coin{Denom: "denom", Amount: sdkmath.NewInt(50)}, - expectedAmount: sdkmath.NewInt(50), - }, - { - name: "coin_no_denom", - inputType: coinType, - coin: sdk.Coin{Amount: sdkmath.NewInt(60)}, - expectedAmount: sdkmath.NewInt(60), - }, - { - name: "coin_no_amount", - inputType: coinType, - coin: sdk.Coin{Denom: "denom"}, - expectedAmount: sdkmath.NewInt(0), - }, - { - name: "zero_coin", - inputType: coinType, - coin: sdk.Coin{Amount: sdkmath.NewInt(0)}, - expectedAmount: sdkmath.NewInt(0), - }, - { - name: "empty_coin", - inputType: coinType, - coin: sdk.Coin{}, - expectedAmount: sdkmath.NewInt(0), - }, - { - name: "positive_int", - inputType: intType, - integer: sdkmath.NewInt(20), - expectedAmount: sdkmath.NewInt(20), - }, - { - name: "zero_int", - inputType: intType, - integer: sdkmath.NewInt(0), - expectedAmount: sdkmath.NewInt(0), - }, - { - name: "empty_int", - inputType: intType, - integer: sdkmath.Int{}, - expectedAmount: sdkmath.NewInt(0), - }, - { - name: "empty_bytes", - inputType: rawBytes, - raw: []byte{}, - expectedAmount: sdkmath.NewInt(0), - }, - { - name: "invalid_bytes", - inputType: rawBytes, - raw: []byte{1, 2}, - expectedError: "unable to unmarshal balance query response", - }, - { - name: "nil_bytes", - inputType: rawBytes, - raw: nil, - expectedError: "query response is nil", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - var args []byte - var err error - switch tc.inputType { - case rawBytes: - args = tc.raw - case coinType: - args, err = tc.coin.Marshal() - case intType: - args, err = tc.integer.Marshal() - } - require.NoError(t, err) - - if tc.expectedError == "" { - actualAmount, err := stakeibckeeper.UnmarshalAmountFromBalanceQuery(stakeibctypes.ModuleCdc, args) - require.NoError(t, err) - require.Equal(t, tc.expectedAmount.Int64(), actualAmount.Int64()) - } else { - _, err := stakeibckeeper.UnmarshalAmountFromBalanceQuery(stakeibctypes.ModuleCdc, args) - require.ErrorContains(t, err, tc.expectedError) - } - }) - } -} diff --git a/x/stakeibc/keeper/msg_server_submit_tx.go b/x/stakeibc/keeper/msg_server_submit_tx.go index 50de45a967..cd3e7cbba9 100644 --- a/x/stakeibc/keeper/msg_server_submit_tx.go +++ b/x/stakeibc/keeper/msg_server_submit_tx.go @@ -173,8 +173,6 @@ func (k Keeper) UpdateWithdrawalBalance(ctx sdk.Context, hostZone types.HostZone ICQCallbackID_WithdrawalBalance, hostZone.ChainId, hostZone.ConnectionId, - // use "bank" store to access acct balances which live in the bank module - // use "key" suffix to retrieve a proof alongside the query result icqtypes.BANK_STORE_QUERY_WITH_PROOF, queryData, ttl, @@ -401,8 +399,6 @@ func (k Keeper) QueryValidatorExchangeRate(ctx sdk.Context, msg *types.MsgUpdate ICQCallbackID_Validator, hostZone.ChainId, hostZone.ConnectionId, - // use "staking" store to access validator which lives in the staking module - // use "key" suffix to retrieve a proof alongside the query result icqtypes.STAKING_STORE_QUERY_WITH_PROOF, queryData, ttl, @@ -454,8 +450,6 @@ func (k Keeper) QueryDelegationsIcq(ctx sdk.Context, hostZone types.HostZone, va ICQCallbackID_Delegation, hostZone.ChainId, hostZone.ConnectionId, - // use "staking" store to access delegation which lives in the staking module - // use "key" suffix to retrieve a proof alongside the query result icqtypes.STAKING_STORE_QUERY_WITH_PROOF, queryData, ttl, diff --git a/x/stakeibc/keeper/reward_allocation.go b/x/stakeibc/keeper/reward_allocation.go new file mode 100644 index 0000000000..ac0b7510ea --- /dev/null +++ b/x/stakeibc/keeper/reward_allocation.go @@ -0,0 +1,85 @@ +package keeper + +import ( + "fmt" + + errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" + + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + + "github.com/Stride-Labs/stride/v6/x/stakeibc/types" +) + +// Liquid Stake Reward Collector Balance +func (k Keeper) LiquidStakeRewardCollectorBalance(ctx sdk.Context, msgSvr types.MsgServer) bool { + k.Logger(ctx).Info("Liquid Staking reward collector balance") + rewardCollectorAddress := k.accountKeeper.GetModuleAccount(ctx, types.RewardCollectorName).GetAddress() + rewardedTokens := k.bankKeeper.GetAllBalances(ctx, rewardCollectorAddress) + if rewardedTokens.IsEqual(sdk.Coins{}) { + k.Logger(ctx).Info("No reward to allocate from RewardCollector") + return false + } + + rewardsAccrued := false + for _, token := range rewardedTokens { + // get hostzone by reward token (in ibc denom format) + hz, err := k.GetHostZoneFromIBCDenom(ctx, token.Denom) + if err != nil { + k.Logger(ctx).Info("Token denom %s in module account is not from a supported host zone", token.Denom) + continue + } + + // liquid stake all tokens + msg := types.NewMsgLiquidStake(rewardCollectorAddress.String(), token.Amount, hz.HostDenom) + if err := msg.ValidateBasic(); err != nil { + k.Logger(ctx).Error("Liquid stake from reward collector address failed validate basic: %s", err.Error()) + continue + } + _, err = msgSvr.LiquidStake(ctx, msg) + if err != nil { + k.Logger(ctx).Error("Can't liquid stake %s for hostzone %s", token.String(), hz.ChainId) + continue + } + k.Logger(ctx).Info(fmt.Sprintf("Liquid staked %s for hostzone %s's accrued rewards", token.String(), hz.ChainId)) + rewardsAccrued = true + } + return rewardsAccrued +} + +// Sweep stTokens from Reward Collector to Fee Collector +func (k Keeper) SweepStTokensFromRewardCollToFeeColl(ctx sdk.Context) error { + // Send all stTokens to fee collector to distribute to delegator later + rewardCollectorAddress := k.accountKeeper.GetModuleAccount(ctx, types.RewardCollectorName).GetAddress() + + rewardCollCoins := k.bankKeeper.GetAllBalances(ctx, rewardCollectorAddress) + k.Logger(ctx).Info(fmt.Sprintf("Reward collector has %s", rewardCollCoins.String())) + stTokens := sdk.NewCoins() + for _, token := range rewardCollCoins { + // get hostzone by reward token (in stToken denom format) + isStToken := k.CheckIsStToken(ctx, token.Denom) + k.Logger(ctx).Info(fmt.Sprintf("%s is stToken: %t", token.String(), isStToken)) + if isStToken { + stTokens = append(stTokens, token) + } + } + k.Logger(ctx).Info(fmt.Sprintf("Sending %s stTokens from %s to %s", stTokens.String(), types.RewardCollectorName, authtypes.FeeCollectorName)) + + err := k.bankKeeper.SendCoinsFromModuleToModule(ctx, types.RewardCollectorName, authtypes.FeeCollectorName, stTokens) + if err != nil { + return errorsmod.Wrapf(err, fmt.Sprintf("Can't send coins from module %s to module %s, err %s", types.RewardCollectorName, authtypes.FeeCollectorName, err.Error())) + } + return nil +} + +// (1) liquid stake reward collector balance, then (2) sweet stTokens from reward collector to fee collector +func (k Keeper) AllocateHostZoneReward(ctx sdk.Context) { + msgSvr := NewMsgServerImpl(k) + if rewardsFound := k.LiquidStakeRewardCollectorBalance(ctx, msgSvr); !rewardsFound { + k.Logger(ctx).Info("No accrued rewards in the reward collector account") + return + } + if err := k.SweepStTokensFromRewardCollToFeeColl(ctx); err != nil { + k.Logger(ctx).Error(fmt.Sprintf("Unable to allocate host zone reward, err: %s", err.Error())) + } +} diff --git a/x/stakeibc/keeper/reward_allocation_test.go b/x/stakeibc/keeper/reward_allocation_test.go index ba64c7571c..92f48e005a 100644 --- a/x/stakeibc/keeper/reward_allocation_test.go +++ b/x/stakeibc/keeper/reward_allocation_test.go @@ -1,192 +1,176 @@ package keeper_test import ( - "fmt" "strings" - _ "github.com/stretchr/testify/suite" - sdk "github.com/cosmos/cosmos-sdk/types" - ibctesting "github.com/cosmos/ibc-go/v5/testing" - icatypes "github.com/cosmos/ibc-go/v5/modules/apps/27-interchain-accounts/types" - clienttypes "github.com/cosmos/ibc-go/v5/modules/core/02-client/types" sdkmath "cosmossdk.io/math" - epochtypes "github.com/Stride-Labs/stride/v6/x/epochs/types" - minttypes "github.com/cosmos/cosmos-sdk/x/mint/types" - stakeibctypes "github.com/Stride-Labs/stride/v6/x/stakeibc/types" "github.com/cosmos/cosmos-sdk/simapp" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" "github.com/cosmos/cosmos-sdk/x/staking/teststaking" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - hosttypes "github.com/cosmos/ibc-go/v5/modules/apps/27-interchain-accounts/host/types" - ibctypes "github.com/cosmos/ibc-go/v5/modules/apps/transfer/types" - channeltypes "github.com/cosmos/ibc-go/v5/modules/core/04-channel/types" + _ "github.com/stretchr/testify/suite" abci "github.com/tendermint/tendermint/abci/types" + + epochtypes "github.com/Stride-Labs/stride/v6/x/epochs/types" recordtypes "github.com/Stride-Labs/stride/v6/x/records/types" + stakeibctypes "github.com/Stride-Labs/stride/v6/x/stakeibc/types" ) -var ( - validators = []*stakeibctypes.Validator{ - { - Name: "val1", - Address: "gaia_VAL1", - Weight: 1, - }, - { - Name: "val2", - Address: "gaia_VAL2", - Weight: 4, - }, +func (s *KeeperTestSuite) SetupTestRewardAllocation() { + // Create two host zones so we can map the ibc and st denom's back to a host zone + // We need valid addresses for the module account addresses, otherwise liquid stake will fail + hostZone1 := stakeibctypes.HostZone{ + ChainId: HostChainId, + HostDenom: Atom, + IbcDenom: IbcAtom, + RedemptionRate: sdk.OneDec(), + Address: stakeibctypes.NewZoneAddress(HostChainId).String(), } - hostModuleAddress = stakeibctypes.NewZoneAddress(HostChainId) -) - -func (s *KeeperTestSuite) SetupWithdrawAccount() (stakeibctypes.HostZone, Channel) { - // Set up host zone ica - delegationAccountOwner := fmt.Sprintf("%s.%s", HostChainId, "DELEGATION") - _ = s.CreateICAChannel(delegationAccountOwner) - delegationAddress := s.IcaAddresses[delegationAccountOwner] - - withdrawalAccountOwner := fmt.Sprintf("%s.%s", HostChainId, "WITHDRAWAL") - withdrawalChannelID := s.CreateICAChannel(withdrawalAccountOwner) - withdrawalAddress := s.IcaAddresses[withdrawalAccountOwner] - - feeAccountOwner := fmt.Sprintf("%s.%s", HostChainId, "FEE") - s.CreateICAChannel(feeAccountOwner) - feeAddress := s.IcaAddresses[feeAccountOwner] - - // Set up ibc denom - ibcDenomTrace := s.GetIBCDenomTrace(Atom) // we need a true IBC denom here - s.App.TransferKeeper.SetDenomTrace(s.Ctx, ibcDenomTrace) - - // Fund withdraw ica - initialModuleAccountBalance := sdk.NewCoin(Atom, sdkmath.NewInt(15_000)) - s.FundAccount(sdk.MustAccAddressFromBech32(withdrawalAddress), initialModuleAccountBalance) - err := s.HostApp.BankKeeper.MintCoins(s.HostChain.GetContext(), minttypes.ModuleName, sdk.NewCoins(initialModuleAccountBalance)) - s.Require().NoError(err) - err = s.HostApp.BankKeeper.SendCoinsFromModuleToAccount(s.HostChain.GetContext(), minttypes.ModuleName, sdk.MustAccAddressFromBech32(withdrawalAddress), sdk.NewCoins(initialModuleAccountBalance)) - s.Require().NoError(err) - - // Allow ica call ibc transfer in host chain - s.HostApp.ICAHostKeeper.SetParams(s.HostChain.GetContext(), hosttypes.Params{ - HostEnabled: true, - AllowMessages: []string{ - "/ibc.applications.transfer.v1.MsgTransfer", - }, - }) - - hostZone := stakeibctypes.HostZone{ - ChainId: HostChainId, - Address: hostModuleAddress.String(), - DelegationAccount: &stakeibctypes.ICAAccount{Address: delegationAddress}, - WithdrawalAccount: &stakeibctypes.ICAAccount{ - Address: withdrawalAddress, - Target: stakeibctypes.ICAAccountType_WITHDRAWAL, - }, - FeeAccount: &stakeibctypes.ICAAccount{ - Address: feeAddress, - Target: stakeibctypes.ICAAccountType_FEE, - }, - ConnectionId: ibctesting.FirstConnectionID, - TransferChannelId: ibctesting.FirstChannelID, - HostDenom: Atom, - IbcDenom: ibcDenomTrace.IBCDenom(), - Validators: validators, + hostZone2 := stakeibctypes.HostZone{ + ChainId: OsmoChainId, + HostDenom: Osmo, + IbcDenom: IbcOsmo, RedemptionRate: sdk.OneDec(), + Address: stakeibctypes.NewZoneAddress(OsmoChainId).String(), } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone1) + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone2) + + // Set epoch tracker and deposit records for liquid stake currentEpoch := uint64(2) strideEpochTracker := stakeibctypes.EpochTracker{ EpochIdentifier: epochtypes.STRIDE_EPOCH, EpochNumber: currentEpoch, - NextEpochStartTime: uint64(s.Coordinator.CurrentTime.UnixNano() + 30_000_000_000), // dictates timeouts - } - mintEpochTracker := stakeibctypes.EpochTracker{ - EpochIdentifier: epochtypes.MINT_EPOCH, - EpochNumber: currentEpoch, - NextEpochStartTime: uint64(s.Coordinator.CurrentTime.UnixNano() + 60_000_000_000), // dictates timeouts + NextEpochStartTime: uint64(10), } - - initialDepositRecord := recordtypes.DepositRecord{ + initialDepositRecord1 := recordtypes.DepositRecord{ Id: 1, - DepositEpochNumber: 2, - HostZoneId: "GAIA", + DepositEpochNumber: currentEpoch, + HostZoneId: HostChainId, + Amount: sdkmath.ZeroInt(), + } + initialDepositRecord2 := recordtypes.DepositRecord{ + Id: 2, + DepositEpochNumber: currentEpoch, + HostZoneId: OsmoChainId, Amount: sdkmath.ZeroInt(), } - - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, strideEpochTracker) - s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, mintEpochTracker) - s.App.RecordsKeeper.SetDepositRecord(s.Ctx, initialDepositRecord) + s.App.RecordsKeeper.SetDepositRecord(s.Ctx, initialDepositRecord1) + s.App.RecordsKeeper.SetDepositRecord(s.Ctx, initialDepositRecord2) +} - return hostZone, Channel{ - PortID: icatypes.PortPrefix + withdrawalAccountOwner, - ChannelID: withdrawalChannelID, - } +// Helper function to check the balance of a module account +func (s *KeeperTestSuite) checkModuleAccountBalance(moduleName, denom string, expectedBalance sdkmath.Int) { + address := s.App.AccountKeeper.GetModuleAccount(s.Ctx, moduleName).GetAddress() + tokens := s.App.BankKeeper.GetBalance(s.Ctx, address, denom) + s.Require().Equal(expectedBalance.Int64(), tokens.Amount.Int64(), "%s %s balance", moduleName, denom) } -func (s *KeeperTestSuite) TestAllocateRewardIBC() { - hz, channel := s.SetupWithdrawAccount() - - rewardCollector := s.App.AccountKeeper.GetModuleAccount(s.Ctx, stakeibctypes.RewardCollectorName) - - // Send tx to withdraw ica to perform ibc transfer from hostzone to stride - var msgs []sdk.Msg - ibcTransferTimeoutNanos := s.App.StakeibcKeeper.GetParam(s.Ctx, stakeibctypes.KeyIBCTransferTimeoutNanos) - timeoutTimestamp := uint64(s.HostChain.GetContext().BlockTime().UnixNano()) + ibcTransferTimeoutNanos - msg := ibctypes.NewMsgTransfer("transfer", "channel-0", sdk.NewCoin(Atom, sdkmath.NewInt(15_000)), hz.WithdrawalAccount.Address, rewardCollector.GetAddress().String(), clienttypes.NewHeight(1, 100), timeoutTimestamp) - msgs = append(msgs, msg) - data, _ := icatypes.SerializeCosmosTx(s.App.AppCodec(), msgs) - icaTimeOutNanos := s.App.StakeibcKeeper.GetParam(s.Ctx, stakeibctypes.KeyICATimeoutNanos) - icaTimeoutTimestamp := uint64(s.StrideChain.GetContext().BlockTime().UnixNano()) + icaTimeOutNanos - - packetData := icatypes.InterchainAccountPacketData{ - Type: icatypes.EXECUTE_TX, - Data: data, - } - packet := channeltypes.NewPacket( - packetData.GetBytes(), - 1, - channel.PortID, - channel.ChannelID, - s.TransferPath.EndpointB.ChannelConfig.PortID, - s.TransferPath.EndpointB.ChannelID, - clienttypes.NewHeight(1, 100), - 0, - ) - _, err := s.App.StakeibcKeeper.SubmitTxs(s.Ctx, hz.ConnectionId, msgs, *hz.WithdrawalAccount, icaTimeoutTimestamp, "", nil) - s.Require().NoError(err) +func (s *KeeperTestSuite) TestLiquidStakeRewardCollectorBalance_Success() { + s.SetupTestRewardAllocation() + rewardAmount := sdkmath.NewInt(1000) - // Simulate the process of receiving ica packets on the hostchain - module, _, err := s.HostChain.App.GetIBCKeeper().PortKeeper.LookupModuleByPort(s.HostChain.GetContext(), "icahost") - s.Require().NoError(err) - cbs, ok := s.HostChain.App.GetIBCKeeper().Router.GetRoute(module) - s.Require().True(ok) - cbs.OnRecvPacket(s.HostChain.GetContext(), packet, nil) - - // After withdraw ica send ibc transfer, simulate receiving transfer packet at stride - transferPacketData := ibctypes.NewFungibleTokenPacketData( - Atom, sdkmath.NewInt(15_000).String(), hz.WithdrawalAccount.Address, rewardCollector.GetAddress().String(), - ) - transferPacketData.Memo = "" - transferPacket := channeltypes.NewPacket( - transferPacketData.GetBytes(), - 1, - s.TransferPath.EndpointB.ChannelConfig.PortID, - s.TransferPath.EndpointB.ChannelID, - s.TransferPath.EndpointA.ChannelConfig.PortID, - s.TransferPath.EndpointA.ChannelID, - clienttypes.NewHeight(1, 100), - 0, - ) - cbs, ok = s.StrideChain.App.GetIBCKeeper().Router.GetRoute("transfer") - s.Require().True(ok) - cbs.OnRecvPacket(s.StrideChain.GetContext(), transferPacket, nil) + // Fund reward collector account with ibc'd reward tokens + s.FundModuleAccount(stakeibctypes.RewardCollectorName, sdk.NewCoin(IbcAtom, rewardAmount)) + s.FundModuleAccount(stakeibctypes.RewardCollectorName, sdk.NewCoin(IbcOsmo, rewardAmount)) // Liquid stake all hostzone token then get stTokens back - // s.App.BeginBlocker(s.Ctx, abci.RequestBeginBlock{}) - err = s.App.StakeibcKeeper.AllocateHostZoneReward(s.Ctx) + rewardsAccrued := s.App.StakeibcKeeper.LiquidStakeRewardCollectorBalance(s.Ctx, s.GetMsgServer()) + s.Require().True(rewardsAccrued, "rewards should have been liquid staked") + + // Reward Collector acct should have all ibc/XXX tokens converted to stTokens + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, StAtom, rewardAmount) + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, StOsmo, rewardAmount) + + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, IbcAtom, sdkmath.ZeroInt()) + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, IbcOsmo, sdkmath.ZeroInt()) +} + +func (s *KeeperTestSuite) TestLiquidStakeRewardCollectorBalance_NoRewardsAccrued() { + s.SetupTestRewardAllocation() + + // With no IBC tokens in the rewards collector account, the liquid stake rewards function should return false + rewardsAccrued := s.App.StakeibcKeeper.LiquidStakeRewardCollectorBalance(s.Ctx, s.GetMsgServer()) + s.Require().False(rewardsAccrued, "no rewards should have been liquid staked") + + // There should also be no stTokens in the account + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, StAtom, sdkmath.ZeroInt()) + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, StOsmo, sdkmath.ZeroInt()) +} + +func (s *KeeperTestSuite) TestLiquidStakeRewardCollectorBalance_BalanceDoesNotBelongToHost() { + s.SetupTestRewardAllocation() + amount := sdkmath.NewInt(1000) + + // Fund the reward collector with ibc/atom and a denom that is not registerd to a host zone + s.FundModuleAccount(stakeibctypes.RewardCollectorName, sdk.NewCoin(IbcAtom, amount)) + s.FundModuleAccount(stakeibctypes.RewardCollectorName, sdk.NewCoin("fake_denom", amount)) + + // Liquid stake should only succeed with atom + rewardsAccrued := s.App.StakeibcKeeper.LiquidStakeRewardCollectorBalance(s.Ctx, s.GetMsgServer()) + s.Require().True(rewardsAccrued, "rewards should have been liquid staked") + + // The atom should have been liquid staked + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, IbcAtom, sdkmath.ZeroInt()) + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, StAtom, amount) + + // But the fake denom and uosmo should not have been touched + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, "fake_denom", amount) + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, StOsmo, sdkmath.ZeroInt()) +} + +func (s *KeeperTestSuite) TestSweepRewardCollToFeeCollector_Success() { + s.SetupTestRewardAllocation() + rewardAmount := sdkmath.NewInt(1000) + + // Add stTokens to reward collector + s.FundModuleAccount(stakeibctypes.RewardCollectorName, sdk.NewCoin(StAtom, rewardAmount)) + s.FundModuleAccount(stakeibctypes.RewardCollectorName, sdk.NewCoin(StOsmo, rewardAmount)) + + // Sweep stTokens from Reward Collector to Fee Collector + err := s.App.StakeibcKeeper.SweepStTokensFromRewardCollToFeeColl(s.Ctx) + s.Require().NoError(err) + + // Fee Collector acct should have stTokens after they're swept there from Reward Collector + // The reward collector should have nothing + s.checkModuleAccountBalance(authtypes.FeeCollectorName, StAtom, rewardAmount) + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, StAtom, sdkmath.ZeroInt()) + + s.checkModuleAccountBalance(authtypes.FeeCollectorName, StOsmo, rewardAmount) + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, StOsmo, sdkmath.ZeroInt()) +} + +func (s *KeeperTestSuite) TestSweepRewardCollToFeeCollector_NonStTokens() { + s.SetupTestRewardAllocation() + amount := sdkmath.NewInt(1000) + nonStTokenDenom := "XXX" + + // Fund reward collector account with stTokens + s.FundModuleAccount(stakeibctypes.RewardCollectorName, sdk.NewCoin(nonStTokenDenom, amount)) + + // Sweep stTokens from Reward Collector to Fee Collector + err := s.App.StakeibcKeeper.SweepStTokensFromRewardCollToFeeColl(s.Ctx) s.Require().NoError(err) - // Set up validator & delegation + // Reward Collector acct should still contain nonStTokenDenom after stTokens after they're swept + s.checkModuleAccountBalance(stakeibctypes.RewardCollectorName, nonStTokenDenom, amount) + + // Fee Collector acct should have nothing + s.checkModuleAccountBalance(authtypes.FeeCollectorName, nonStTokenDenom, sdkmath.ZeroInt()) +} + +// Test the process of a delegator claiming staking reward stTokens (tests that Fee Account can distribute arbitrary denoms) +func (s *KeeperTestSuite) TestClaimStakingRewardStTokens() { + s.SetupTestRewardAllocation() + amount := sdkmath.NewInt(1000) + + // Fund fee collector account with stTokens + s.FundModuleAccount(authtypes.FeeCollectorName, sdk.NewCoin("st"+Atom, amount)) + + // Set up validators & delegators on Stride addrs := s.TestAccs for _, acc := range addrs { s.FundAccount(acc, sdk.NewCoin(sdk.DefaultBondDenom, sdk.NewInt(1000000))) @@ -194,45 +178,51 @@ func (s *KeeperTestSuite) TestAllocateRewardIBC() { valAddrs := simapp.ConvertAddrsToValAddrs(addrs) tstaking := teststaking.NewHelper(s.T(), s.Ctx, s.App.StakingKeeper) - PK := simapp.CreateTestPubKeys(2) + pubkeys := simapp.CreateTestPubKeys(2) + stakeAmount := sdk.NewInt(100) // create validator with 50% commission - tstaking.Commission = stakingtypes.NewCommissionRates(sdk.NewDecWithPrec(5, 1), sdk.NewDecWithPrec(5, 1), sdk.NewDec(0)) - tstaking.CreateValidator(valAddrs[0], PK[0], sdk.NewInt(100), true) + commission := sdk.NewDecWithPrec(5, 1) + tstaking.Commission = stakingtypes.NewCommissionRates(commission, commission, sdk.NewDec(0)) + tstaking.CreateValidator(valAddrs[0], pubkeys[0], stakeAmount, true) // create second validator with 0% commission - tstaking.Commission = stakingtypes.NewCommissionRates(sdk.NewDec(0), sdk.NewDec(0), sdk.NewDec(0)) - tstaking.CreateValidator(valAddrs[1], PK[1], sdk.NewInt(100), true) + commission = sdk.NewDec(0) + tstaking.Commission = stakingtypes.NewCommissionRates(commission, commission, sdk.NewDec(0)) + tstaking.CreateValidator(valAddrs[1], pubkeys[1], stakeAmount, true) s.App.EndBlocker(s.Ctx, abci.RequestEndBlock{}) s.Ctx = s.Ctx.WithBlockHeight(s.Ctx.BlockHeight() + 1) // Simulate the token distribution from feeCollector to validators abciValA := abci.Validator{ - Address: PK[0].Address(), + Address: pubkeys[0].Address(), Power: 100, } abciValB := abci.Validator{ - Address: PK[1].Address(), + Address: pubkeys[1].Address(), Power: 100, } votes := []abci.VoteInfo{ { - Validator: abciValA, + Validator: abciValA, SignedLastBlock: true, }, { - Validator: abciValB, + Validator: abciValB, SignedLastBlock: true, }, } - s.App.DistrKeeper.AllocateTokens(s.Ctx, 200, 200, sdk.ConsAddress(PK[1].Address()), votes) + s.App.DistrKeeper.AllocateTokens(s.Ctx, 200, 200, sdk.ConsAddress(pubkeys[1].Address()), votes) - // Withdraw reward - rewards, err := s.App.DistrKeeper.WithdrawDelegationRewards(s.Ctx, sdk.AccAddress(valAddrs[1]), valAddrs[1]) - s.Require().NoError(err) + // Withdraw rewards + rewards1, err := s.App.DistrKeeper.WithdrawDelegationRewards(s.Ctx, sdk.AccAddress(valAddrs[0]), valAddrs[0]) + s.Require().NoError(err, "no error expected with withdrawing delegator rewards") - // Check balances contains stTokens - s.Require().True(strings.Contains(rewards.String(), "stuatom")) + rewards2, err := s.App.DistrKeeper.WithdrawDelegationRewards(s.Ctx, sdk.AccAddress(valAddrs[1]), valAddrs[1]) + s.Require().NoError(err, "no error expected with withdrawing delegator rewards") + // Check balances contains stTokens + s.Require().True(strings.Contains(rewards1.String(), "stuatom")) + s.Require().True(strings.Contains(rewards2.String(), "stuatom")) } diff --git a/x/stakeibc/types/errors.go b/x/stakeibc/types/errors.go index e2cc057bba..7072af92ec 100644 --- a/x/stakeibc/types/errors.go +++ b/x/stakeibc/types/errors.go @@ -46,6 +46,7 @@ var ( ErrNoValidatorAmts = errorsmod.Register(ModuleName, 1538, "could not fetch validator amts") ErrMaxNumValidators = errorsmod.Register(ModuleName, 1539, "max number of validators reached") ErrUndelegationAmount = errorsmod.Register(ModuleName, 1540, "Undelegation amount is greater than stakedBal") - ErrHaltedHostZone = errorsmod.Register(ModuleName, 1541, "Halted host zone found") - ErrInsufficientLiquidStake = errorsmod.Register(ModuleName, 1542, "Liquid staked amount is too small") + ErrRewardCollectorAccountNotFound = errorsmod.Register(ModuleName, 1541, "Reward Collector account not found") + ErrHaltedHostZone = errorsmod.Register(ModuleName, 1542, "Halted host zone found") + ErrInsufficientLiquidStake = errorsmod.Register(ModuleName, 1543, "Liquid staked amount is too small") )