Unverified Commit 73bd4e00 authored by mergify[bot]'s avatar mergify[bot] Committed by GitHub
Browse files

Add type safety to query whitelist (#2656) (#2662)

* Add type safety to query whitelist

* bring back commented out tests

* add godoc

(cherry picked from commit b888f888

)

Co-authored-by: default avatarDev Ojha <ValarDragon@users.noreply.github.com>
parent b63e061a
Showing with 109 additions and 86 deletions
+109 -86
package wasmbinding
import "github.com/cosmos/cosmos-sdk/codec"
func SetWhitelistedQuery(queryPath string, protoType codec.ProtoMarshaler) {
setWhitelistedQuery(queryPath, protoType)
}
......@@ -15,11 +15,11 @@ import (
)
// StargateQuerier dispatches whitelisted stargate queries
func StargateQuerier(queryRouter baseapp.GRPCQueryRouter, codec codec.Codec) func(ctx sdk.Context, request *wasmvmtypes.StargateQuery) ([]byte, error) {
func StargateQuerier(queryRouter baseapp.GRPCQueryRouter, cdc codec.Codec) func(ctx sdk.Context, request *wasmvmtypes.StargateQuery) ([]byte, error) {
return func(ctx sdk.Context, request *wasmvmtypes.StargateQuery) ([]byte, error) {
protoResponse, whitelisted := StargateWhitelist.Load(request.Path)
if !whitelisted {
return nil, wasmvmtypes.UnsupportedRequest{Kind: fmt.Sprintf("'%s' path is not allowed from the contract", request.Path)}
protoResponseType, err := GetWhitelistedQuery(request.Path)
if err != nil {
return nil, err
}
route := queryRouter.Route(request.Path)
......@@ -35,7 +35,7 @@ func StargateQuerier(queryRouter baseapp.GRPCQueryRouter, codec codec.Codec) fun
return nil, err
}
bz, err := ConvertProtoToJSONMarshal(protoResponse, res.Value, codec)
bz, err := ConvertProtoToJSONMarshal(protoResponseType, res.Value, cdc)
if err != nil {
return nil, err
}
......@@ -174,25 +174,19 @@ func CustomQuerier(qp *QueryPlugin) func(ctx sdk.Context, request json.RawMessag
// ConvertProtoToJsonMarshal unmarshals the given bytes into a proto message and then marshals it to json.
// This is done so that clients calling stargate queries do not need to define their own proto unmarshalers,
// being able to use response directly by json marshalling, which is supported in cosmwasm.
func ConvertProtoToJSONMarshal(protoResponse interface{}, bz []byte, cdc codec.Codec) ([]byte, error) {
// all values are proto message
message, ok := protoResponse.(codec.ProtoMarshaler)
if !ok {
return nil, wasmvmtypes.Unknown{}
}
func ConvertProtoToJSONMarshal(protoResponseType codec.ProtoMarshaler, bz []byte, cdc codec.Codec) ([]byte, error) {
// unmarshal binary into stargate response data structure
err := cdc.Unmarshal(bz, message)
err := cdc.Unmarshal(bz, protoResponseType)
if err != nil {
return nil, wasmvmtypes.Unknown{}
}
bz, err = cdc.MarshalJSON(message)
bz, err = cdc.MarshalJSON(protoResponseType)
if err != nil {
return nil, wasmvmtypes.Unknown{}
}
message.Reset()
protoResponseType.Reset()
return bz, nil
}
......
......@@ -7,6 +7,7 @@ import (
"time"
wasmvmtypes "github.com/CosmWasm/wasmvm/types"
"github.com/cosmos/cosmos-sdk/codec"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
"github.com/cosmos/cosmos-sdk/simapp"
sdk "github.com/cosmos/cosmos-sdk/types"
......@@ -16,7 +17,6 @@ import (
proto "github.com/golang/protobuf/proto"
"github.com/stretchr/testify/suite"
tmproto "github.com/tendermint/tendermint/proto/tendermint/types"
"google.golang.org/protobuf/runtime/protoiface"
"github.com/osmosis-labs/osmosis/v12/app"
epochtypes "github.com/osmosis-labs/osmosis/v12/x/epochs/types"
......@@ -84,7 +84,7 @@ func (suite *StargateTestSuite) TestStargateQuerier() {
// fund account to recieve non-empty response
simapp.FundAccount(suite.app.BankKeeper, suite.ctx, accAddr, sdk.Coins{sdk.NewCoin("stake", sdk.NewInt(10))})
wasmbinding.StargateWhitelist.Store("/cosmos.bank.v1beta1.Query/AllBalances", &banktypes.QueryAllBalancesResponse{})
wasmbinding.SetWhitelistedQuery("/cosmos.bank.v1beta1.Query/AllBalances", &banktypes.QueryAllBalancesResponse{})
},
path: "/cosmos.bank.v1beta1.Query/AllBalances",
requestData: func() []byte {
......@@ -106,7 +106,7 @@ func (suite *StargateTestSuite) TestStargateQuerier() {
// fund account to recieve non-empty response
simapp.FundAccount(suite.app.BankKeeper, suite.ctx, accAddr, sdk.Coins{sdk.NewCoin("stake", sdk.NewInt(10))})
wasmbinding.StargateWhitelist.Store("/cosmos.bank.v1beta1.Query/AllBalances", &banktypes.QueryAllBalancesResponse{})
wasmbinding.SetWhitelistedQuery("/cosmos.bank.v1beta1.Query/AllBalances", &banktypes.QueryAllBalancesResponse{})
},
path: "/cosmos.bank.v1beta1.Query/AllBalances",
requestData: func() []byte {
......@@ -123,7 +123,7 @@ func (suite *StargateTestSuite) TestStargateQuerier() {
{
name: "invalid query router route",
testSetup: func() {
wasmbinding.StargateWhitelist.Store("invalid/query/router/route", epochtypes.QueryEpochsInfoRequest{})
wasmbinding.SetWhitelistedQuery("invalid/query/router/route", &epochtypes.QueryEpochsInfoRequest{})
},
path: "invalid/query/router/route",
requestData: func() []byte {
......@@ -147,7 +147,8 @@ func (suite *StargateTestSuite) TestStargateQuerier() {
name: "error in unmarshalling response",
// set up whitelist with wrong data
testSetup: func() {
wasmbinding.StargateWhitelist.Store("/osmosis.epochs.v1beta1.Query/EpochInfos", interface{}(nil))
wasmbinding.SetWhitelistedQuery("/osmosis.epochs.v1beta1.Query/EpochInfos",
&banktypes.QueryAllBalancesResponse{})
},
path: "/osmosis.epochs.v1beta1.Query/EpochInfos",
requestData: func() []byte {
......@@ -160,7 +161,7 @@ func (suite *StargateTestSuite) TestStargateQuerier() {
name: "error in grpc querier",
// set up whitelist with wrong data
testSetup: func() {
wasmbinding.StargateWhitelist.Store("/cosmos.bank.v1beta1.Query/AllBalances", banktypes.QueryAllBalancesRequest{})
wasmbinding.SetWhitelistedQuery("/cosmos.bank.v1beta1.Query/AllBalances", &banktypes.QueryAllBalancesRequest{})
},
path: "/cosmos.bank.v1beta1.Query/AllBalances",
requestData: func() []byte {
......@@ -191,20 +192,20 @@ func (suite *StargateTestSuite) TestStargateQuerier() {
if tc.expectedQuerierError {
suite.Require().Error(err)
return
} else {
suite.Require().NoError(err)
}
protoResponse, ok := tc.responseProtoStruct.(proto.Message)
suite.Require().True(ok)
suite.Require().NoError(err)
// test correctness by unmarshalling json response into proto struct
err = suite.app.AppCodec().UnmarshalJSON(stargateResponse, protoResponse)
if tc.expectedUnMarshalError {
suite.Require().Error(err)
} else {
suite.Require().NoError(err)
suite.Require().NotNil(protoResponse)
}
protoResponse, ok := tc.responseProtoStruct.(proto.Message)
suite.Require().True(ok)
// test correctness by unmarshalling json response into proto struct
err = suite.app.AppCodec().UnmarshalJSON(stargateResponse, protoResponse)
if tc.expectedUnMarshalError {
suite.Require().Error(err)
} else {
suite.Require().NoError(err)
suite.Require().NotNil(protoResponse)
}
if tc.resendRequest {
......@@ -225,9 +226,9 @@ func (suite *StargateTestSuite) TestConvertProtoToJsonMarshal() {
testCases := []struct {
name string
queryPath string
protoResponseStruct proto.Message
protoResponseStruct codec.ProtoMarshaler
originalResponse string
expectedProtoResponse proto.Message
expectedProtoResponse codec.ProtoMarshaler
expectedError bool
}{
{
......@@ -246,7 +247,7 @@ func (suite *StargateTestSuite) TestConvertProtoToJsonMarshal() {
name: "invalid proto response struct",
queryPath: "/cosmos.bank.v1beta1.Query/AllBalances",
originalResponse: "0a090a036261721202333012050a03666f6f",
protoResponseStruct: protoiface.MessageV1(nil),
protoResponseStruct: &epochtypes.QueryCurrentEpochResponse{},
expectedError: true,
},
}
......@@ -317,7 +318,7 @@ func (suite *StargateTestSuite) TestDeterministicJsonMarshal() {
{
"Query All Balances",
func() {
wasmbinding.StargateWhitelist.Store("/cosmos.bank.v1beta1.Query/AllBalances", &banktypes.QueryAllBalancesResponse{})
wasmbinding.SetWhitelistedQuery("/cosmos.bank.v1beta1.Query/AllBalances", &banktypes.QueryAllBalancesResponse{})
},
"0a090a036261721202333012050a03666f6f",
"0a090a036261721202333012050a03666f6f1a2d636f736d6f73316a366a357473717571326a6c77326166376c3378656b796171377a67346c386a737566753738",
......@@ -380,8 +381,8 @@ func (suite *StargateTestSuite) TestDeterministicJsonMarshal() {
tc.testSetup()
}
binding, ok := wasmbinding.StargateWhitelist.Load(tc.queryPath)
suite.Require().True(ok)
binding, err := wasmbinding.GetWhitelistedQuery(tc.queryPath)
suite.Require().Nil(err)
originVersionBz, err := hex.DecodeString(tc.originalResponse)
suite.Require().NoError(err)
......
package wasmbinding
import (
"fmt"
"sync"
wasmvmtypes "github.com/CosmWasm/wasmvm/types"
"github.com/cosmos/cosmos-sdk/codec"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
distributiontypes "github.com/cosmos/cosmos-sdk/x/distribution/types"
......@@ -20,88 +23,106 @@ import (
txfeestypes "github.com/osmosis-labs/osmosis/v12/x/txfees/types"
)
// StargateWhitelist keeps whitelist and its deterministic
// stargateWhitelist keeps whitelist and its deterministic
// response binding for stargate queries.
//
// The query can be multi-thread, so we have to use
// thread safe sync.Map.
var StargateWhitelist sync.Map
var stargateWhitelist sync.Map
func init() {
// cosmos-sdk queries
// auth
StargateWhitelist.Store("/cosmos.auth.v1beta1.Query/Account", &authtypes.QueryAccountResponse{})
StargateWhitelist.Store("/cosmos.auth.v1beta1.Query/Params", &authtypes.QueryParamsResponse{})
setWhitelistedQuery("/cosmos.auth.v1beta1.Query/Account", &authtypes.QueryAccountResponse{})
setWhitelistedQuery("/cosmos.auth.v1beta1.Query/Params", &authtypes.QueryParamsResponse{})
// bank
StargateWhitelist.Store("/cosmos.bank.v1beta1.Query/Balance", &banktypes.QueryBalanceResponse{})
StargateWhitelist.Store("/cosmos.bank.v1beta1.Query/DenomMetadata", &banktypes.QueryDenomsMetadataResponse{})
StargateWhitelist.Store("/cosmos.bank.v1beta1.Query/Params", &banktypes.QueryParamsResponse{})
StargateWhitelist.Store("/cosmos.bank.v1beta1.Query/SupplyOf", &banktypes.QuerySupplyOfResponse{})
setWhitelistedQuery("/cosmos.bank.v1beta1.Query/Balance", &banktypes.QueryBalanceResponse{})
setWhitelistedQuery("/cosmos.bank.v1beta1.Query/DenomMetadata", &banktypes.QueryDenomsMetadataResponse{})
setWhitelistedQuery("/cosmos.bank.v1beta1.Query/Params", &banktypes.QueryParamsResponse{})
setWhitelistedQuery("/cosmos.bank.v1beta1.Query/SupplyOf", &banktypes.QuerySupplyOfResponse{})
// distribution
StargateWhitelist.Store("/cosmos.distribution.v1beta1.Query/Params", &distributiontypes.QueryParamsResponse{})
StargateWhitelist.Store("/cosmos.distribution.v1beta1.Query/DelegatorWithdrawAddress", &distributiontypes.QueryDelegatorWithdrawAddressResponse{})
StargateWhitelist.Store("/cosmos.distribution.v1beta1.Query/ValidatorCommission", &distributiontypes.QueryValidatorCommissionResponse{})
setWhitelistedQuery("/cosmos.distribution.v1beta1.Query/Params", &distributiontypes.QueryParamsResponse{})
setWhitelistedQuery("/cosmos.distribution.v1beta1.Query/DelegatorWithdrawAddress", &distributiontypes.QueryDelegatorWithdrawAddressResponse{})
setWhitelistedQuery("/cosmos.distribution.v1beta1.Query/ValidatorCommission", &distributiontypes.QueryValidatorCommissionResponse{})
// gov
StargateWhitelist.Store("/cosmos.gov.v1beta1.Query/Deposit", &govtypes.QueryDepositResponse{})
StargateWhitelist.Store("/cosmos.gov.v1beta1.Query/Params", &govtypes.QueryParamsResponse{})
StargateWhitelist.Store("/cosmos.gov.v1beta1.Query/Vote", &govtypes.QueryVoteResponse{})
setWhitelistedQuery("/cosmos.gov.v1beta1.Query/Deposit", &govtypes.QueryDepositResponse{})
setWhitelistedQuery("/cosmos.gov.v1beta1.Query/Params", &govtypes.QueryParamsResponse{})
setWhitelistedQuery("/cosmos.gov.v1beta1.Query/Vote", &govtypes.QueryVoteResponse{})
// slashing
StargateWhitelist.Store("/cosmos.slashing.v1beta1.Query/Params", &slashingtypes.QueryParamsResponse{})
StargateWhitelist.Store("/cosmos.slashing.v1beta1.Query/SigningInfo", &slashingtypes.QuerySigningInfoResponse{})
setWhitelistedQuery("/cosmos.slashing.v1beta1.Query/Params", &slashingtypes.QueryParamsResponse{})
setWhitelistedQuery("/cosmos.slashing.v1beta1.Query/SigningInfo", &slashingtypes.QuerySigningInfoResponse{})
// staking
StargateWhitelist.Store("/cosmos.staking.v1beta1.Query/Delegation", &stakingtypes.QueryDelegationResponse{})
StargateWhitelist.Store("/cosmos.staking.v1beta1.Query/Params", &stakingtypes.QueryParamsResponse{})
StargateWhitelist.Store("/cosmos.staking.v1beta1.Query/Validator", &stakingtypes.QueryValidatorResponse{})
setWhitelistedQuery("/cosmos.staking.v1beta1.Query/Delegation", &stakingtypes.QueryDelegationResponse{})
setWhitelistedQuery("/cosmos.staking.v1beta1.Query/Params", &stakingtypes.QueryParamsResponse{})
setWhitelistedQuery("/cosmos.staking.v1beta1.Query/Validator", &stakingtypes.QueryValidatorResponse{})
// osmosis queries
//epochs
StargateWhitelist.Store("/osmosis.epochs.v1beta1.Query/EpochInfos", &epochtypes.QueryEpochsInfoResponse{})
StargateWhitelist.Store("/osmosis.epochs.v1beta1.Query/CurrentEpoch", &epochtypes.QueryCurrentEpochResponse{})
setWhitelistedQuery("/osmosis.epochs.v1beta1.Query/EpochInfos", &epochtypes.QueryEpochsInfoResponse{})
setWhitelistedQuery("/osmosis.epochs.v1beta1.Query/CurrentEpoch", &epochtypes.QueryCurrentEpochResponse{})
// gamm
StargateWhitelist.Store("/osmosis.gamm.v1beta1.Query/NumPools", &gammtypes.QueryNumPoolsResponse{})
StargateWhitelist.Store("/osmosis.gamm.v1beta1.Query/TotalLiquidity", &gammtypes.QueryTotalLiquidityResponse{})
StargateWhitelist.Store("/osmosis.gamm.v1beta1.Query/Pool", &gammtypes.QueryPoolResponse{})
StargateWhitelist.Store("/osmosis.gamm.v1beta1.Query/PoolParams", &gammtypes.QueryPoolParamsResponse{})
StargateWhitelist.Store("/osmosis.gamm.v1beta1.Query/TotalPoolLiquidity", &gammtypes.QueryTotalPoolLiquidityResponse{})
StargateWhitelist.Store("/osmosis.gamm.v1beta1.Query/TotalShares", &gammtypes.QueryTotalSharesResponse{})
StargateWhitelist.Store("/osmosis.gamm.v1beta1.Query/SpotPrice", &gammtypes.QuerySpotPriceResponse{})
setWhitelistedQuery("/osmosis.gamm.v1beta1.Query/NumPools", &gammtypes.QueryNumPoolsResponse{})
setWhitelistedQuery("/osmosis.gamm.v1beta1.Query/TotalLiquidity", &gammtypes.QueryTotalLiquidityResponse{})
setWhitelistedQuery("/osmosis.gamm.v1beta1.Query/Pool", &gammtypes.QueryPoolResponse{})
setWhitelistedQuery("/osmosis.gamm.v1beta1.Query/PoolParams", &gammtypes.QueryPoolParamsResponse{})
setWhitelistedQuery("/osmosis.gamm.v1beta1.Query/TotalPoolLiquidity", &gammtypes.QueryTotalPoolLiquidityResponse{})
setWhitelistedQuery("/osmosis.gamm.v1beta1.Query/TotalShares", &gammtypes.QueryTotalSharesResponse{})
setWhitelistedQuery("/osmosis.gamm.v1beta1.Query/SpotPrice", &gammtypes.QuerySpotPriceResponse{})
// incentives
StargateWhitelist.Store("/osmosis.incentives.Query/ModuleToDistributeCoins", &incentivestypes.ModuleToDistributeCoinsResponse{})
StargateWhitelist.Store("/osmosis.incentives.Query/ModuleDistributedCoins", &incentivestypes.ModuleDistributedCoinsResponse{})
StargateWhitelist.Store("/osmosis.incentives.Query/LockableDurations", &incentivestypes.QueryLockableDurationsResponse{})
setWhitelistedQuery("/osmosis.incentives.Query/ModuleToDistributeCoins", &incentivestypes.ModuleToDistributeCoinsResponse{})
setWhitelistedQuery("/osmosis.incentives.Query/ModuleDistributedCoins", &incentivestypes.ModuleDistributedCoinsResponse{})
setWhitelistedQuery("/osmosis.incentives.Query/LockableDurations", &incentivestypes.QueryLockableDurationsResponse{})
// lockup
StargateWhitelist.Store("/osmosis.lockup.Query/ModuleBalance", &lockuptypes.ModuleBalanceResponse{})
StargateWhitelist.Store("/osmosis.lockup.Query/ModuleLockedAmount", &lockuptypes.ModuleLockedAmountResponse{})
StargateWhitelist.Store("/osmosis.lockup.Query/AccountUnlockableCoins", &lockuptypes.AccountUnlockableCoinsResponse{})
StargateWhitelist.Store("/osmosis.lockup.Query/AccountUnlockingCoins", &lockuptypes.AccountUnlockingCoinsResponse{})
StargateWhitelist.Store("/osmosis.lockup.Query/LockedDenom", &lockuptypes.LockedDenomResponse{})
setWhitelistedQuery("/osmosis.lockup.Query/ModuleBalance", &lockuptypes.ModuleBalanceResponse{})
setWhitelistedQuery("/osmosis.lockup.Query/ModuleLockedAmount", &lockuptypes.ModuleLockedAmountResponse{})
setWhitelistedQuery("/osmosis.lockup.Query/AccountUnlockableCoins", &lockuptypes.AccountUnlockableCoinsResponse{})
setWhitelistedQuery("/osmosis.lockup.Query/AccountUnlockingCoins", &lockuptypes.AccountUnlockingCoinsResponse{})
setWhitelistedQuery("/osmosis.lockup.Query/LockedDenom", &lockuptypes.LockedDenomResponse{})
// mint
StargateWhitelist.Store("/osmosis.mint.v1beta1.Query/EpochProvisions", &minttypes.QueryEpochProvisionsResponse{})
StargateWhitelist.Store("/osmosis.mint.v1beta1.Query/Params", &minttypes.QueryParamsResponse{})
setWhitelistedQuery("/osmosis.mint.v1beta1.Query/EpochProvisions", &minttypes.QueryEpochProvisionsResponse{})
setWhitelistedQuery("/osmosis.mint.v1beta1.Query/Params", &minttypes.QueryParamsResponse{})
// pool-incentives
StargateWhitelist.Store("/osmosis.poolincentives.v1beta1.Query/GaugeIds", &poolincentivestypes.QueryGaugeIdsResponse{})
setWhitelistedQuery("/osmosis.poolincentives.v1beta1.Query/GaugeIds", &poolincentivestypes.QueryGaugeIdsResponse{})
// superfluid
StargateWhitelist.Store("/osmosis.superfluid.Query/Params", &superfluidtypes.QueryParamsResponse{})
StargateWhitelist.Store("/osmosis.superfluid.Query/AssetType", &superfluidtypes.AssetTypeResponse{})
StargateWhitelist.Store("/osmosis.superfluid.Query/AllAssets", &superfluidtypes.AllAssetsResponse{})
StargateWhitelist.Store("/osmosis.superfluid.Query/AssetMultiplier", &superfluidtypes.AssetMultiplierResponse{})
setWhitelistedQuery("/osmosis.superfluid.Query/Params", &superfluidtypes.QueryParamsResponse{})
setWhitelistedQuery("/osmosis.superfluid.Query/AssetType", &superfluidtypes.AssetTypeResponse{})
setWhitelistedQuery("/osmosis.superfluid.Query/AllAssets", &superfluidtypes.AllAssetsResponse{})
setWhitelistedQuery("/osmosis.superfluid.Query/AssetMultiplier", &superfluidtypes.AssetMultiplierResponse{})
// txfees
StargateWhitelist.Store("/osmosis.txfees.v1beta1.Query/FeeTokens", &txfeestypes.QueryFeeTokensResponse{})
StargateWhitelist.Store("/osmosis.txfees.v1beta1.Query/DenomSpotPrice", &txfeestypes.QueryDenomSpotPriceResponse{})
StargateWhitelist.Store("/osmosis.txfees.v1beta1.Query/DenomPoolId", &txfeestypes.QueryDenomPoolIdResponse{})
StargateWhitelist.Store("/osmosis.txfees.v1beta1.Query/BaseDenom", &txfeestypes.QueryBaseDenomResponse{})
setWhitelistedQuery("/osmosis.txfees.v1beta1.Query/FeeTokens", &txfeestypes.QueryFeeTokensResponse{})
setWhitelistedQuery("/osmosis.txfees.v1beta1.Query/DenomSpotPrice", &txfeestypes.QueryDenomSpotPriceResponse{})
setWhitelistedQuery("/osmosis.txfees.v1beta1.Query/DenomPoolId", &txfeestypes.QueryDenomPoolIdResponse{})
setWhitelistedQuery("/osmosis.txfees.v1beta1.Query/BaseDenom", &txfeestypes.QueryBaseDenomResponse{})
}
// GetWhitelistedQuery returns the whitelisted query at the provided path.
// If the query does not exist, or it was setup wrong by the chain, this returns an error.
func GetWhitelistedQuery(queryPath string) (codec.ProtoMarshaler, error) {
protoResponseAny, isWhitelisted := stargateWhitelist.Load(queryPath)
if !isWhitelisted {
return nil, wasmvmtypes.UnsupportedRequest{Kind: fmt.Sprintf("'%s' path is not allowed from the contract", queryPath)}
}
protoResponseType, ok := protoResponseAny.(codec.ProtoMarshaler)
if !ok {
return nil, wasmvmtypes.Unknown{}
}
return protoResponseType, nil
}
func setWhitelistedQuery(queryPath string, protoType codec.ProtoMarshaler) {
stargateWhitelist.Store(queryPath, protoType)
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment