diff --git a/x/crosschain/keeper/abci.go b/x/crosschain/keeper/abci.go index bc73652b3..e680011ac 100644 --- a/x/crosschain/keeper/abci.go +++ b/x/crosschain/keeper/abci.go @@ -61,7 +61,8 @@ func (k Keeper) slashing(ctx sdk.Context, signedWindow uint64) { oracles := k.GetAllOracles(ctx, true) oracleSetHasSlash := k.oracleSetSlashing(ctx, oracles, signedWindow) batchHasSlash := k.batchSlashing(ctx, oracles, signedWindow) - if oracleSetHasSlash || batchHasSlash { + refundHasSlash := k.refundSlashing(ctx, signedWindow) + if oracleSetHasSlash || batchHasSlash || refundHasSlash { k.CommonSetOracleTotalPower(ctx) } } @@ -121,6 +122,46 @@ func (k Keeper) batchSlashing(ctx sdk.Context, oracles types.Oracles, signedWind return hasSlash } +func (k Keeper) refundSlashing(ctx sdk.Context, signedWindow uint64) (hasSlash bool) { + maxHeight := uint64(ctx.BlockHeight()) - signedWindow + unSlashRefunds := k.GetUnSlashedRefundRecords(ctx, maxHeight) + + snapshotOracleMap := make(map[uint64]*types.SnapshotOracle) + for _, record := range unSlashRefunds { + confirmOracleMap := make(map[string]bool) + k.IterRefundConfirmByNonce(ctx, record.EventNonce, func(confirm *types.MsgConfirmRefund) bool { + confirmOracleMap[confirm.ExternalAddress] = true + return false + }) + + snapshotOracle, found := snapshotOracleMap[record.OracleSetNonce] + if !found { + snapshotOracle, found = k.GetSnapshotOracle(ctx, record.OracleSetNonce) + if !found { + k.Logger(ctx).Error("refund slashing", "oracle set not found", record.OracleSetNonce) + continue + } + snapshotOracleMap[record.OracleSetNonce] = snapshotOracle + } + + for _, members := range snapshotOracle.GetMembers() { + if _, ok := confirmOracleMap[members.ExternalAddress]; !ok { + oracle, found := k.GetOracleByExternalAddress(ctx, members.ExternalAddress) + if !found { + k.Logger(ctx).Error("refund slashing", "oracle not found", members.ExternalAddress) + continue + } + k.Logger(ctx).Info("slash oracle by refund", "externalAddress", members.ExternalAddress, + "oracleAddress", oracle.String(), "recordNonce", record.EventNonce, "blockHeight", ctx.BlockHeight()) + k.SlashOracle(ctx, oracle.String()) + hasSlash = true + } + } + k.SetLastSlashedRefundNonce(ctx, record.EventNonce) + } + return hasSlash +} + // cleanupTimedOutBatches deletes batches that have passed their expiration on Ethereum // keep in mind several things when modifying this function // A) unlike nonces timeouts are not monotonically increasing, meaning batch 5 can have a later timeout than batch 6 diff --git a/x/crosschain/keeper/abci_test.go b/x/crosschain/keeper/abci_test.go index 53049eac0..a170dc406 100644 --- a/x/crosschain/keeper/abci_test.go +++ b/x/crosschain/keeper/abci_test.go @@ -13,6 +13,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" abci "github.com/tendermint/tendermint/abci/types" + tmrand "github.com/tendermint/tendermint/libs/rand" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -674,3 +675,30 @@ func (suite *KeeperTestSuite) TestCleanUpRefundTimeout() { _, err = suite.QueryClient().RefundRecordByNonce(sdk.WrapSDKContext(suite.ctx), &types.QueryRefundRecordByNonceRequest{ChainName: suite.chainName, EventNonce: 2}) suite.ErrorIs(err, status.Error(codes.NotFound, "refund record"), suite.chainName) } + +func (suite *KeeperTestSuite) TestRefundSlashing() { + suite.bondedOracle() + suite.Commit() + + eventNonce := tmrand.Uint64() + + err := suite.Keeper().AddRefundRecord(suite.ctx, helpers.GenerateZeroAddressByModule(suite.chainName), eventNonce, []types.ERC20Token{}) + suite.NoError(err) + + params := suite.Keeper().GetParams(suite.ctx) + signedWindow := uint64(tmrand.Int63n(10) + 2) + params.SignedWindow = signedWindow + suite.NoError(suite.Keeper().SetParams(suite.ctx, ¶ms)) + + slashedRefundNonce := suite.Keeper().GetLastSlashedRefundNonce(suite.ctx) + suite.EqualValues(0, slashedRefundNonce) + suite.Commit(int64(signedWindow)) + + slashedRefundNonce = suite.Keeper().GetLastSlashedRefundNonce(suite.ctx) + suite.EqualValues(eventNonce, slashedRefundNonce) + + oracle, found := suite.Keeper().GetOracle(suite.ctx, suite.oracleAddrs[0]) + suite.True(found) + suite.False(oracle.Online) + suite.EqualValues(1, oracle.SlashTimes) +} diff --git a/x/crosschain/keeper/bridge_call_refund.go b/x/crosschain/keeper/bridge_call_refund.go index d49c6bbb1..14cd1f755 100644 --- a/x/crosschain/keeper/bridge_call_refund.go +++ b/x/crosschain/keeper/bridge_call_refund.go @@ -1,6 +1,8 @@ package keeper import ( + "math" + errorsmod "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" @@ -188,3 +190,42 @@ func (k Keeper) DeleteRefundConfirm(ctx sdk.Context, nonce uint64) { store.Delete(iterator.Key()) } } + +func (k Keeper) SetLastSlashedRefundNonce(ctx sdk.Context, nonce uint64) { + store := ctx.KVStore(k.storeKey) + store.Set(types.LastSlashedRefundNonce, sdk.Uint64ToBigEndian(nonce)) +} + +func (k Keeper) GetLastSlashedRefundNonce(ctx sdk.Context) uint64 { + store := ctx.KVStore(k.storeKey) + return sdk.BigEndianToUint64(store.Get(types.LastSlashedRefundNonce)) +} + +func (k Keeper) GetUnSlashedRefundRecords(ctx sdk.Context, height uint64) []types.RefundRecord { + nonce := k.GetLastSlashedRefundNonce(ctx) + var refunds []types.RefundRecord + k.IterateRefundRecordByNonce(ctx, nonce, func(record *types.RefundRecord) bool { + if record.Block <= height { + refunds = append(refunds, *record) + return false + } + return true + }) + return refunds +} + +func (k Keeper) IterateRefundRecordByNonce(ctx sdk.Context, startNonce uint64, cb func(record *types.RefundRecord) bool) { + store := ctx.KVStore(k.storeKey) + startKey := append(types.BridgeCallRefundEventNonceKey, sdk.Uint64ToBigEndian(startNonce)...) + endKey := append(types.BridgeCallRefundEventNonceKey, sdk.Uint64ToBigEndian(math.MaxUint64)...) + iter := store.Iterator(startKey, endKey) + defer iter.Close() + + for ; iter.Valid(); iter.Next() { + value := new(types.RefundRecord) + k.cdc.MustUnmarshal(iter.Value(), value) + if cb(value) { + break + } + } +} diff --git a/x/crosschain/keeper/bridge_call_refund_test.go b/x/crosschain/keeper/bridge_call_refund_test.go index fa8506e53..22d70b5ff 100644 --- a/x/crosschain/keeper/bridge_call_refund_test.go +++ b/x/crosschain/keeper/bridge_call_refund_test.go @@ -1,6 +1,8 @@ package keeper_test import ( + "testing" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/functionx/fx-core/v7/testutil/helpers" @@ -31,3 +33,47 @@ func (suite *KeeperTestSuite) TestKeeper_AddRefundRecord() { suite.Equal(refundRecord.Receiver, receiver) suite.Equal(refundRecord.Tokens, tokens) } + +func (suite *KeeperTestSuite) TestKeeper_IterateRefundRecordByNonce() { + testCases := []struct { + name string + eventNonces []uint64 + startNonce uint64 + expectEventNonce []uint64 + }{ + { + name: "only 1 - start 0", + eventNonces: []uint64{1}, + startNonce: uint64(0), + expectEventNonce: []uint64{1}, + }, + { + name: "only 1 - start 1", + eventNonces: []uint64{1}, + startNonce: uint64(1), + expectEventNonce: []uint64{1}, + }, + { + name: "out-of-order", + eventNonces: []uint64{6, 2, 5}, + startNonce: uint64(1), + expectEventNonce: []uint64{2, 5, 6}, + }, + } + + for _, testCase := range testCases { + suite.T().Run(testCase.name, func(t *testing.T) { + suite.SetupTest() + for _, nonce := range testCase.eventNonces { + suite.Keeper().SetRefundRecord(suite.ctx, &types.RefundRecord{EventNonce: nonce}) + } + actualEventNonces := make([]uint64, 0, len(testCase.expectEventNonce)) + suite.Keeper().IterateRefundRecordByNonce(suite.ctx, testCase.startNonce, func(record *types.RefundRecord) bool { + actualEventNonces = append(actualEventNonces, record.EventNonce) + return false + }) + + suite.EqualValues(testCase.expectEventNonce, actualEventNonces) + }) + } +} diff --git a/x/crosschain/keeper/msg_server_test.go b/x/crosschain/keeper/msg_server_test.go index 09d93293f..fac12effb 100644 --- a/x/crosschain/keeper/msg_server_test.go +++ b/x/crosschain/keeper/msg_server_test.go @@ -1305,7 +1305,7 @@ func (suite *KeeperTestSuite) TestConfirmRefund() { } } -func (suite *KeeperTestSuite) bondedOracle() uint64 { +func (suite *KeeperTestSuite) bondedOracle() { _, err := suite.MsgServer().BondedOracle(sdk.WrapSDKContext(suite.ctx), &types.MsgBondedOracle{ OracleAddress: suite.oracleAddrs[0].String(), BridgerAddress: suite.bridgerAddrs[0].String(), @@ -1318,7 +1318,6 @@ func (suite *KeeperTestSuite) bondedOracle() uint64 { oracleLastEventNonce := suite.Keeper().GetLastEventNonceByOracle(suite.ctx, suite.oracleAddrs[0]) require.EqualValues(suite.T(), 0, oracleLastEventNonce) - return oracleLastEventNonce } func (suite *KeeperTestSuite) addBridgeToken(tokenContract string, md banktypes.Metadata) { diff --git a/x/crosschain/types/key.go b/x/crosschain/types/key.go index b8537e34d..7ff881bcd 100644 --- a/x/crosschain/types/key.go +++ b/x/crosschain/types/key.go @@ -116,6 +116,9 @@ var ( SnapshotOracleKey = []byte{0x44} BridgeCallRefundConfirmKey = []byte{0x45} + + // LastSlashedRefundNonce indexes the latest slashed refund nonce + LastSlashedRefundNonce = []byte{0x46} ) // GetOracleKey returns the following key format