wasmd/x/wasm/keeper/recurse_test.go

321 lines
9.1 KiB
Go

package keeper
import (
"encoding/json"
"testing"
wasmvmtypes "github.com/CosmWasm/wasmvm/v3/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
storetypes "cosmossdk.io/store/types"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/CosmWasm/wasmd/x/wasm/types"
)
type Recurse struct {
Depth uint32 `json:"depth"`
Work uint32 `json:"work"`
}
type recurseWrapper struct {
Recurse Recurse `json:"recurse"`
}
func buildRecurseQuery(t *testing.T, msg Recurse) []byte {
t.Helper()
wrapper := recurseWrapper{Recurse: msg}
bz, err := json.Marshal(wrapper)
require.NoError(t, err)
return bz
}
type recurseResponse struct {
Hashed []byte `json:"hashed"`
}
// number os wasm queries called from a contract
var totalWasmQueryCounter int
func initRecurseContract(t *testing.T) (contract sdk.AccAddress, ctx sdk.Context, keeper *Keeper) {
t.Helper()
countingQuerierDec := func(realWasmQuerier WasmVMQueryHandler) WasmVMQueryHandler {
return WasmVMQueryHandlerFn(func(ctx sdk.Context, caller sdk.AccAddress, request wasmvmtypes.QueryRequest) ([]byte, error) {
totalWasmQueryCounter++
return realWasmQuerier.HandleQuery(ctx, caller, request)
})
}
ctx, keepers := CreateTestInput(t, false, AvailableCapabilities, WithQueryHandlerDecorator(countingQuerierDec))
keeper = keepers.WasmKeeper
exampleContract := InstantiateHackatomExampleContract(t, ctx, keepers)
return exampleContract.Contract, ctx, keeper
}
func TestGasCostOnQuery(t *testing.T) {
const (
GasNoWork uint64 = 64_209
GasWork50 uint64 = 64_456
// should be discounted exactly by the difference between normal instance cost and discounted instance cost
GasNoWorkDiscounted uint64 = GasNoWork - (types.DefaultInstanceCost - types.DefaultInstanceCostDiscount)
GasWork50Discounted uint64 = GasWork50 - (types.DefaultInstanceCost - types.DefaultInstanceCostDiscount)
GasReturnUnhashed uint64 = 74
GasReturnHashed uint64 = 63
)
cases := map[string]struct {
gasLimit uint64
msg Recurse
expectedGas uint64
}{
"no recursion, no work": {
gasLimit: 400_000,
msg: Recurse{},
expectedGas: GasNoWork,
},
"no recursion, some work": {
gasLimit: 400_000,
msg: Recurse{
Work: 50, // 50 rounds of sha256 inside the contract
},
expectedGas: GasWork50,
},
"recursion 1, no work": {
gasLimit: 400_000,
msg: Recurse{
Depth: 1,
},
expectedGas: GasNoWork + GasNoWorkDiscounted + GasReturnUnhashed,
},
"recursion 1, some work": {
gasLimit: 400_000,
msg: Recurse{
Depth: 1,
Work: 50,
},
expectedGas: GasWork50 + GasWork50Discounted + GasReturnHashed,
},
"recursion 4, some work": {
gasLimit: 400_000,
msg: Recurse{
Depth: 4,
Work: 50,
},
expectedGas: GasWork50 + 4*(GasWork50Discounted+GasReturnHashed),
},
}
contractAddr, ctx, keeper := initRecurseContract(t)
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
// external limit has no effect (we get a panic if this is enforced)
keeper.queryGasLimit = 1000
// make sure we set a limit before calling
ctx = ctx.WithGasMeter(storetypes.NewGasMeter(tc.gasLimit))
// init tx contracts in ctx
ctx = types.WithTxContracts(ctx, types.NewTxContracts())
require.Equal(t, uint64(0), ctx.GasMeter().GasConsumed())
// do the query
recurse := tc.msg
msg := buildRecurseQuery(t, recurse)
data, err := keeper.QuerySmart(ctx, contractAddr, msg)
require.NoError(t, err)
// check the gas is what we expected
if types.EnableGasVerification {
assert.Equal(t, tc.expectedGas, ctx.GasMeter().GasConsumed())
}
// assert result is 32 byte sha256 hash (if hashed), or contractAddr if not
var resp recurseResponse
err = json.Unmarshal(data, &resp)
require.NoError(t, err)
if recurse.Work == 0 {
assert.Equal(t, len(contractAddr.String()), len(resp.Hashed))
} else {
assert.Equal(t, 32, len(resp.Hashed))
}
})
}
}
func TestGasOnExternalQuery(t *testing.T) {
const (
GasWork50 uint64 = types.DefaultInstanceCost + 8_464
)
cases := map[string]struct {
gasLimit uint64
msg Recurse
expOutOfGas bool
}{
"no recursion, plenty gas": {
gasLimit: 400_000,
msg: Recurse{
Work: 50, // 50 rounds of sha256 inside the contract
},
},
"recursion 4, plenty gas": {
// this uses 244708 gas
gasLimit: 400_000,
msg: Recurse{
Depth: 4,
Work: 50,
},
},
"no recursion, external gas limit": {
gasLimit: 5000, // this is not enough
msg: Recurse{
Work: 50,
},
expOutOfGas: true,
},
"recursion 4, external gas limit": {
gasLimit: GasWork50,
msg: Recurse{
Depth: 4,
Work: 50,
},
expOutOfGas: true,
},
}
contractAddr, ctx, keeper := initRecurseContract(t)
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
recurse := tc.msg
msg := buildRecurseQuery(t, recurse)
querier := NewGrpcQuerier(keeper.cdc, keeper.storeService, keeper, tc.gasLimit)
req := &types.QuerySmartContractStateRequest{Address: contractAddr.String(), QueryData: msg}
_, gotErr := querier.SmartContractState(ctx, req)
if tc.expOutOfGas {
require.Error(t, gotErr, sdkerrors.ErrOutOfGas)
return
}
require.NoError(t, gotErr)
})
}
}
func TestLimitRecursiveQueryGas(t *testing.T) {
// The point of this test from https://github.com/CosmWasm/cosmwasm/issues/456
// Basically, if I burn 90% of gas in CPU loop, then query out (to my self)
// the sub-query will have all the original gas (minus the 40k instance charge)
// and can burn 90% and call a sub-contract again...
// This attack would allow us to use far more than the provided gas before
// eventually hitting an OutOfGas panic.
const (
// Note: about 100 SDK gas (10k CosmWasm gas) for each round of sha256
GasWork2k uint64 = 77_038 // = SetupContractCost + x // we have 6x gas used in cpu than in the instance
// should be discounted exactly by the difference between normal instance cost and discounted instance cost
GasWork2kDiscounted uint64 = GasWork2k - (types.DefaultInstanceCost - types.DefaultInstanceCostDiscount)
// This is overhead for calling into a sub-contract
GasReturnHashed uint64 = 64
// lots of additional gas for long error message
GasError uint64 = 3408
)
cases := map[string]struct {
gasLimit uint64
msg Recurse
expectQueriesFromContract int
expectedGas uint64
expectOutOfGas bool
expectError string
}{
"no recursion, lots of work": {
gasLimit: 4_000_000,
msg: Recurse{
Depth: 0,
Work: 2000,
},
expectQueriesFromContract: 0,
expectedGas: GasWork2k,
},
"recursion 5, lots of work": {
gasLimit: GasWork2k + 5*(GasWork2kDiscounted+GasReturnHashed) + 1,
msg: Recurse{
Depth: 5,
Work: 2000,
},
expectQueriesFromContract: 5,
expectedGas: GasWork2k + 5*(GasWork2kDiscounted+GasReturnHashed),
},
// this is where we expect an error...
// it has enough gas to run 5 times and die on the 6th (5th time dispatching to sub-contract)
// however, if we don't charge the cpu gas before sub-dispatching, we can recurse over 20 times
"deep recursion, should die on 6th level": {
gasLimit: GasWork2k + 5*(GasWork2kDiscounted+GasReturnHashed),
msg: Recurse{
Depth: 6,
Work: 2000,
},
expectQueriesFromContract: 6,
expectOutOfGas: true,
},
"very deep recursion, hits recursion limit": {
gasLimit: 10_000_000,
msg: Recurse{
Depth: 100,
Work: 2000,
},
expectQueriesFromContract: 10,
expectOutOfGas: false,
expectError: "query wasm contract failed", // Error we get from the contract instance doing the failing query, not wasmd
expectedGas: GasWork2k + GasReturnHashed + 9*(GasWork2kDiscounted+GasReturnHashed) + GasError,
},
}
contractAddr, ctx, keeper := initRecurseContract(t)
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
// reset the counter before test
totalWasmQueryCounter = 0
// make sure we set a limit before calling
ctx = ctx.WithGasMeter(storetypes.NewGasMeter(tc.gasLimit))
// init tx contracts in ctx
ctx = types.WithTxContracts(ctx, types.NewTxContracts())
require.Equal(t, uint64(0), ctx.GasMeter().GasConsumed())
// prepare the query
recurse := tc.msg
msg := buildRecurseQuery(t, recurse)
// if we expect out of gas, make sure this panics
if tc.expectOutOfGas {
require.Panics(t, func() {
_, err := keeper.QuerySmart(ctx, contractAddr, msg)
t.Logf("Got error not panic: %#v", err)
})
assert.Equal(t, tc.expectQueriesFromContract, totalWasmQueryCounter)
return
}
// otherwise, we expect a successful call
_, err := keeper.QuerySmart(ctx, contractAddr, msg)
if tc.expectError != "" {
require.ErrorContains(t, err, tc.expectError)
} else {
require.NoError(t, err)
}
if types.EnableGasVerification {
assert.Equal(t, tc.expectedGas, ctx.GasMeter().GasConsumed())
}
assert.Equal(t, tc.expectQueriesFromContract, totalWasmQueryCounter)
})
}
}