diff --git a/protocol/lib/metrics/metric_keys.go b/protocol/lib/metrics/metric_keys.go index c7c42b4c44..c84abc2fb1 100644 --- a/protocol/lib/metrics/metric_keys.go +++ b/protocol/lib/metrics/metric_keys.go @@ -89,4 +89,9 @@ const ( EndBlocker = "end_blocker" EndBlockerLag = "end_blocker_lag" + + // Account plus + AuthenticatorDecoratorAnteHandleLatency = "authenticator_decorator_ante_handle_latency" + MissingRegisteredAuthenticator = "missing_registered_authenticator" + AuthenticatorTrackFailed = "authenticator_track_failed" ) diff --git a/protocol/x/accountplus/ante/ante.go b/protocol/x/accountplus/ante/ante.go new file mode 100644 index 0000000000..af3e636dff --- /dev/null +++ b/protocol/x/accountplus/ante/ante.go @@ -0,0 +1,298 @@ +package ante + +import ( + "bytes" + "strconv" + "time" + + "github.com/cosmos/cosmos-sdk/codec" + + errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + + txsigning "cosmossdk.io/x/tx/signing" + + "github.com/dydxprotocol/v4-chain/protocol/lib/metrics" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/authenticator" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/keeper" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/types" +) + +// AuthenticatorDecorator is responsible for processing authentication logic +// before transaction execution. +type AuthenticatorDecorator struct { + accountPlusKeeper *keeper.Keeper + accountKeeper authante.AccountKeeper + sigModeHandler *txsigning.HandlerMap + cdc codec.Codec +} + +// NewAuthenticatorDecorator creates a new instance of AuthenticatorDecorator with the provided parameters. +func NewAuthenticatorDecorator( + cdc codec.Codec, + accountPlusKeeper *keeper.Keeper, + accountKeeper authante.AccountKeeper, + sigModeHandler *txsigning.HandlerMap, +) AuthenticatorDecorator { + return AuthenticatorDecorator{ + accountPlusKeeper: accountPlusKeeper, + accountKeeper: accountKeeper, + sigModeHandler: sigModeHandler, + cdc: cdc, + } +} + +// AnteHandle is the authenticator ante handler responsible for processing authentication +// logic before transaction execution. +func (ad AuthenticatorDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (newCtx sdk.Context, err error) { + defer metrics.ModuleMeasureSince( + types.ModuleName, + metrics.AuthenticatorDecoratorAnteHandleLatency, + time.Now(), + ) + + // Make sure smart account is active. + if active := ad.accountPlusKeeper.GetIsSmartAccountActive(ctx); !active { + return ctx, types.ErrSmartAccountNotActive + } + + // Authenticators don't support manually setting the fee payer + err = ad.ValidateAuthenticatorFeePayer(tx) + if err != nil { + return sdk.Context{}, err + } + + msgs := tx.GetMsgs() + if len(msgs) == 0 { + return ctx, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "no messages in transaction") + } + + feeTx, ok := tx.(sdk.FeeTx) + if !ok { + return ctx, errorsmod.Wrap(sdkerrors.ErrTxDecode, "Tx must be a FeeTx") + } + + // The fee payer is the first signer of the transaction. This should have been enforced by the + // LimitFeePayerDecorator + signers, _, err := ad.cdc.GetMsgV1Signers(msgs[0]) + if err != nil { + return ctx, errorsmod.Wrap(sdkerrors.ErrUnauthorized, "failed to get signers") + } + feePayer := sdk.AccAddress(signers[0]) + feeGranter := feeTx.FeeGranter() + fee := feeTx.GetFee() + + selectedAuthenticators, err := ad.GetSelectedAuthenticators(tx, len(msgs)) + if err != nil { + return ctx, err + } + + // tracks are used to make sure that we only write to the store after every message is successful + var tracks []func() error + + // Authenticate the accounts of all messages + for msgIndex, msg := range msgs { + signers, _, err := ad.cdc.GetMsgV1Signers(msg) + if err != nil { + return ctx, errorsmod.Wrap(sdkerrors.ErrUnauthorized, "failed to get signers") + } + // Enforce only one signer per message + if len(signers) != 1 { + return sdk.Context{}, errorsmod.Wrap(sdkerrors.ErrUnauthorized, "messages must have exactly one signer") + } + + // By default, the first signer is the account that is used + account := sdk.AccAddress(signers[0]) + + // Get the currently selected authenticator + selectedAuthenticatorId := int(selectedAuthenticators[msgIndex]) + selectedAuthenticator, err := ad.accountPlusKeeper.GetInitializedAuthenticatorForAccount( + ctx, + account, + selectedAuthenticatorId, + ) + if err != nil { + return sdk.Context{}, + errorsmod.Wrapf( + err, + "failed to get initialized authenticator "+ + "(account = %s, authenticator id = %d, msg index = %d, msg type url = %s)", + account, + selectedAuthenticatorId, + msgIndex, + sdk.MsgTypeURL(msg), + ) + } + + // Generate the authentication request data + authenticationRequest, err := authenticator.GenerateAuthenticationRequest( + ctx, + ad.cdc, + ad.accountKeeper, + ad.sigModeHandler, + account, + feePayer, + feeGranter, + fee, + msg, + tx, + msgIndex, + simulate, + ) + if err != nil { + return sdk.Context{}, + errorsmod.Wrapf( + err, + "failed to generate authentication data "+ + "(account = %s, authenticator id = %d, msg index = %d, msg type url = %s)", + account, + selectedAuthenticator.Id, + msgIndex, + sdk.MsgTypeURL(msg), + ) + } + + a11r := selectedAuthenticator.Authenticator + stringId := strconv.FormatUint(selectedAuthenticator.Id, 10) + authenticationRequest.AuthenticatorId = stringId + + // Consume the authenticator's static gas + ctx.GasMeter().ConsumeGas(a11r.StaticGas(), "authenticator static gas") + + // Authenticate should never modify state. That's what track is for + neverWriteCtx, _ := ctx.CacheContext() + authErr := a11r.Authenticate(neverWriteCtx, authenticationRequest) + + // If authentication is successful, continue + if authErr == nil { + // Append the track closure to be called after every message is authenticated + // Note: pre-initialize type URL to avoid closure issues from passing a msg + // loop variable inside the closure. + currentMsgTypeURL := sdk.MsgTypeURL(msg) + tracks = append(tracks, func() error { + err := a11r.Track(ctx, authenticationRequest) + if err != nil { + // track should not fail in normal circumstances, + // since it is intended to update track state before execution. + // If it does fail, we log the error. + metrics.IncrCounter(metrics.AuthenticatorTrackFailed, 1) + ad.accountPlusKeeper.Logger(ctx).Error( + "track failed", + "account", account, + "feePayer", feePayer, + "msg", currentMsgTypeURL, + "authenticatorId", stringId, + "error", err, + ) + + return errorsmod.Wrapf( + err, + "track failed (account = %s, authenticator id = %s, authenticator type, %s, msg index = %d)", + account, + stringId, + a11r.Type(), + msgIndex, + ) + } + return nil + }) + } + + // If authentication failed, return an error + if authErr != nil { + return ctx, errorsmod.Wrapf( + authErr, + "authentication failed for message %d, authenticator id %d, type %s", + msgIndex, + selectedAuthenticator.Id, + selectedAuthenticator.Authenticator.Type(), + ) + } + } + + // If the transaction has been authenticated, we call Track(...) on every message + // to notify its authenticator so that it can handle any state updates. + for _, track := range tracks { + if err := track(); err != nil { + return sdk.Context{}, err + } + } + + return next(ctx, tx, simulate) +} + +// ValidateAuthenticatorFeePayer enforces that the tx fee payer has not been set manually +// to an account different to the signer of the first message. This is a requirement +// for the authenticator module. +// The only user of a manually set fee payer is with fee grants, which are not +// available on osmosis +func (ad AuthenticatorDecorator) ValidateAuthenticatorFeePayer(tx sdk.Tx) error { + feeTx, ok := tx.(sdk.FeeTx) + if !ok { + return errorsmod.Wrap(sdkerrors.ErrTxDecode, "Tx must be a FeeTx") + } + + // The fee payer by default is the first signer of the transaction + feePayer := feeTx.FeePayer() + + msgs := tx.GetMsgs() + if len(msgs) == 0 { + return errorsmod.Wrap(sdkerrors.ErrTxDecode, "Tx must contain at least one message") + } + signers, _, err := ad.cdc.GetMsgV1Signers(msgs[0]) + if err != nil { + return errorsmod.Wrap(sdkerrors.ErrUnauthorized, "failed to get signers") + } + if len(signers) == 0 { + return errorsmod.Wrap(sdkerrors.ErrTxDecode, "Tx message must contain at least one signer") + } + + if !bytes.Equal(feePayer, signers[0]) { + return errorsmod.Wrap(sdkerrors.ErrUnauthorized, "fee payer must be the first signer") + } + return nil +} + +// GetSelectedAuthenticators retrieves the selected authenticators for the provided transaction extension +// and matches them with the number of messages in the transaction. +// If no selected authenticators are found in the extension, the function initializes the list with -1 values. +// It returns an array of selected authenticators or an error if the number of selected authenticators does not match +// the number of messages in the transaction. +func (ad AuthenticatorDecorator) GetSelectedAuthenticators( + tx sdk.Tx, + msgCount int, +) ([]uint64, error) { + extTx, ok := tx.(authante.HasExtensionOptionsTx) + if !ok { + return nil, errorsmod.Wrap(sdkerrors.ErrTxDecode, "Tx must be a HasExtensionOptionsTx to use Authenticators") + } + + // Get the selected authenticator options from the transaction. + txOptions := ad.accountPlusKeeper.GetAuthenticatorExtension(extTx.GetNonCriticalExtensionOptions()) + if txOptions == nil { + return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, + "Cannot get AuthenticatorTxOptions from tx") + } + // Retrieve the selected authenticators from the extension. + selectedAuthenticators := txOptions.GetSelectedAuthenticators() + + if len(selectedAuthenticators) != msgCount { + // Return an error if the number of selected authenticators does not match the number of messages. + return nil, errorsmod.Wrapf( + sdkerrors.ErrInvalidRequest, + "Mismatch between the number of selected authenticators and messages, "+ + "msg count %d, got %d selected authenticators", + msgCount, + len(selectedAuthenticators), + ) + } + + return selectedAuthenticators, nil +} diff --git a/protocol/x/accountplus/ante/ante_test.go b/protocol/x/accountplus/ante/ante_test.go new file mode 100644 index 0000000000..802b1e1551 --- /dev/null +++ b/protocol/x/accountplus/ante/ante_test.go @@ -0,0 +1,503 @@ +package ante_test + +import ( + "encoding/hex" + "fmt" + "math/rand" + "os" + "testing" + "time" + + storetypes "cosmossdk.io/store/types" + tmtypes "github.com/cometbft/cometbft/types" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/simulation" + "github.com/cosmos/cosmos-sdk/types/tx/signing" + authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + + "github.com/stretchr/testify/suite" + + "github.com/dydxprotocol/v4-chain/protocol/app" + "github.com/dydxprotocol/v4-chain/protocol/app/config" + testapp "github.com/dydxprotocol/v4-chain/protocol/testutil/app" + "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/ante" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/types" +) + +type AuthenticatorAnteSuite struct { + suite.Suite + + tApp *testapp.TestApp + Ctx sdk.Context + EncodingConfig app.EncodingConfig + AuthenticatorDecorator ante.AuthenticatorDecorator + TestKeys []string + TestAccAddress []sdk.AccAddress + TestPrivKeys []*secp256k1.PrivKey + HomeDir string +} + +func TestAuthenticatorAnteSuite(t *testing.T) { + suite.Run(t, new(AuthenticatorAnteSuite)) +} + +func (s *AuthenticatorAnteSuite) SetupTest() { + // Test data for authenticator signature verification + TestKeys := []string{ + "6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159", + "0dd4d1506e18a5712080708c338eb51ecf2afdceae01e8162e890b126ac190fe", + "49006a359803f0602a7ec521df88bf5527579da79112bb71f285dd3e7d438033", + } + + s.HomeDir = fmt.Sprintf("%d", rand.Int()) + + // Set up test accounts + accounts := make([]sdk.AccountI, 0) + for _, key := range TestKeys { + bz, _ := hex.DecodeString(key) + priv := &secp256k1.PrivKey{Key: bz} + + // Add the test private keys to an array for later use + s.TestPrivKeys = append(s.TestPrivKeys, priv) + + // Generate an account address from the public key + accAddress := sdk.AccAddress(priv.PubKey().Address()) + accounts = append( + accounts, + authtypes.NewBaseAccount(accAddress, priv.PubKey(), 0, 0), + ) + + // Add the test accounts' addresses to an array for later use + s.TestAccAddress = append(s.TestAccAddress, accAddress) + } + + s.tApp = testapp.NewTestAppBuilder(s.T()).WithGenesisDocFn(func() (genesis tmtypes.GenesisDoc) { + genesis = testapp.DefaultGenesis() + testapp.UpdateGenesisDocWithAppStateForModule( + &genesis, + func(genesisState *authtypes.GenesisState) { + for _, acct := range accounts { + genesisState.Accounts = append(genesisState.Accounts, codectypes.UnsafePackAny(acct)) + } + }, + ) + return genesis + }).Build() + s.Ctx = s.tApp.InitChain() + + s.EncodingConfig = app.GetEncodingConfig() + s.AuthenticatorDecorator = ante.NewAuthenticatorDecorator( + s.tApp.App.AppCodec(), + &s.tApp.App.AccountPlusKeeper, + s.tApp.App.AccountKeeper, + s.EncodingConfig.TxConfig.SignModeHandler(), + ) + s.Ctx = s.Ctx.WithGasMeter(storetypes.NewGasMeter(1_000_000)) +} + +func (s *AuthenticatorAnteSuite) TearDownTest() { + os.RemoveAll(s.HomeDir) +} + +// TestSignatureVerificationNoAuthenticatorInStore test a non-smart account signature verification +// with no authenticator in the store +func (s *AuthenticatorAnteSuite) TestSignatureVerificationNoAuthenticatorInStore() { + bech32Prefix := config.Bech32PrefixAccAddr + coins := sdk.Coins{sdk.NewInt64Coin(constants.TestNativeTokenDenom, 2500)} + + // Create a test messages for signing + testMsg1 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[0]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + testMsg2 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + feeCoins := constants.TestFeeCoins_5Cents + + tx, _ := GenTx( + s.Ctx, + s.EncodingConfig.TxConfig, + []sdk.Msg{ + testMsg1, + testMsg2, + }, + feeCoins, + 300000, + s.Ctx.ChainID(), + []uint64{6, 6}, + []uint64{0, 0}, + []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + s.TestPrivKeys[1], + }, + []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + s.TestPrivKeys[1], + }, + []uint64{0, 0}, + ) + + anteHandler := sdk.ChainAnteDecorators(s.AuthenticatorDecorator) + _, err := anteHandler(s.Ctx, tx, false) + + s.Require().Error(err, "Expected error when no authenticator is in the store") +} + +// TestSignatureVerificationWithAuthenticatorInStore test a non-smart account signature verification +// with a single authenticator in the store +func (s *AuthenticatorAnteSuite) TestSignatureVerificationWithAuthenticatorInStore() { + bech32Prefix := config.Bech32PrefixAccAddr + coins := sdk.Coins{sdk.NewInt64Coin(constants.TestNativeTokenDenom, 2500)} + + // Create a test messages for signing + testMsg1 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[0]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + testMsg2 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + feeCoins := constants.TestFeeCoins_5Cents + + id, err := s.tApp.App.AccountPlusKeeper.AddAuthenticator( + s.Ctx, + s.TestAccAddress[0], + "SignatureVerification", + s.TestPrivKeys[0].PubKey().Bytes(), + ) + s.Require().NoError(err) + s.Require().Equal(id, uint64(0), "Adding authenticator returning incorrect id") + + id, err = s.tApp.App.AccountPlusKeeper.AddAuthenticator( + s.Ctx, + s.TestAccAddress[1], + "SignatureVerification", + s.TestPrivKeys[1].PubKey().Bytes(), + ) + s.Require().NoError(err) + s.Require().Equal(id, uint64(1), "Adding authenticator returning incorrect id") + + s.tApp.App.AccountPlusKeeper.SetActiveState(s.Ctx, true) + s.Require().True( + s.tApp.App.AccountPlusKeeper.GetIsSmartAccountActive(s.Ctx), + "Expected smart account to be active", + ) + + tx, _ := GenTx( + s.Ctx, + s.EncodingConfig.TxConfig, + []sdk.Msg{ + testMsg1, + testMsg2, + }, + feeCoins, + 300000, + s.Ctx.ChainID(), + []uint64{5, 6}, + []uint64{0, 0}, + []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + s.TestPrivKeys[1], + }, + []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + s.TestPrivKeys[1], + }, + []uint64{0, 1}, + ) + + anteHandler := sdk.ChainAnteDecorators(s.AuthenticatorDecorator) + _, err = anteHandler(s.Ctx, tx, false) + + s.Require().NoError(err) +} + +// TestFeePayerGasComsumption tests that the fee payer only gets charged gas for the transaction once. +func (s *AuthenticatorAnteSuite) TestFeePayerGasComsumption() { + bech32Prefix := config.Bech32PrefixAccAddr + coins := sdk.Coins{sdk.NewInt64Coin(constants.TestNativeTokenDenom, 2500)} + feeCoins := constants.TestFeeCoins_5Cents + + specifiedGasLimit := uint64(300_000) + + // Create two messages to ensure that the fee payer code path is reached twice + testMsg1 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[0]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + + testMsg2 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[0]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + + // Add a signature verification authenticator to the account + sigId, err := s.tApp.App.AccountPlusKeeper.AddAuthenticator( + s.Ctx, + s.TestAccAddress[0], + "SignatureVerification", + s.TestPrivKeys[1].PubKey().Bytes(), + ) + s.Require().NoError(err) + s.Require().Equal(sigId, uint64(0), "Adding authenticator returning incorrect id") + + s.tApp.App.AccountPlusKeeper.SetActiveState(s.Ctx, true) + s.Require().True( + s.tApp.App.AccountPlusKeeper.GetIsSmartAccountActive(s.Ctx), + "Expected smart account to be active", + ) + + tx, _ := GenTx( + s.Ctx, + s.EncodingConfig.TxConfig, + []sdk.Msg{ + testMsg1, + testMsg2, + }, + feeCoins, + specifiedGasLimit, + s.Ctx.ChainID(), + []uint64{5, 5}, + []uint64{0, 0}, + []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + }, + []cryptotypes.PrivKey{ + s.TestPrivKeys[1], + }, + []uint64{sigId, sigId}, + ) + + anteHandler := sdk.ChainAnteDecorators(s.AuthenticatorDecorator) + _, err = anteHandler(s.Ctx, tx, false) + s.Require().NoError(err) +} + +func (s *AuthenticatorAnteSuite) TestSpecificAuthenticator() { + bech32Prefix := config.Bech32PrefixAccAddr + coins := sdk.Coins{sdk.NewInt64Coin(constants.TestNativeTokenDenom, 2500)} + feeCoins := constants.TestFeeCoins_5Cents + + // Create a test messages for signing + testMsg1 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + + sig1Id, err := s.tApp.App.AccountPlusKeeper.AddAuthenticator( + s.Ctx, + s.TestAccAddress[1], + "SignatureVerification", + s.TestPrivKeys[0].PubKey().Bytes(), + ) + s.Require().NoError(err) + s.Require().Equal(sig1Id, uint64(0), "Adding authenticator returning incorrect id") + + sig2Id, err := s.tApp.App.AccountPlusKeeper.AddAuthenticator( + s.Ctx, + s.TestAccAddress[1], + "SignatureVerification", + s.TestPrivKeys[1].PubKey().Bytes(), + ) + s.Require().NoError(err) + s.Require().Equal(sig2Id, uint64(1), "Adding authenticator returning incorrect id") + + s.tApp.App.AccountPlusKeeper.SetActiveState(s.Ctx, true) + s.Require().True( + s.tApp.App.AccountPlusKeeper.GetIsSmartAccountActive(s.Ctx), + "Expected smart account to be active", + ) + + testCases := map[string]struct { + name string + senderKey cryptotypes.PrivKey + signKey cryptotypes.PrivKey + selectedAuthenticator []uint64 + shouldPass bool + }{ + "Correct authenticator 0": { + senderKey: s.TestPrivKeys[0], + signKey: s.TestPrivKeys[0], + selectedAuthenticator: []uint64{sig1Id}, + shouldPass: true, + }, + "Correct authenticator 1": { + senderKey: s.TestPrivKeys[0], + signKey: s.TestPrivKeys[1], + selectedAuthenticator: []uint64{sig2Id}, + shouldPass: true, + }, + "Incorrect authenticator 0": { + senderKey: s.TestPrivKeys[0], + signKey: s.TestPrivKeys[0], + selectedAuthenticator: []uint64{sig2Id}, + shouldPass: false, + }, + "Incorrect authenticator 1": { + senderKey: s.TestPrivKeys[0], + signKey: s.TestPrivKeys[1], + selectedAuthenticator: []uint64{sig1Id}, + shouldPass: false, + }, + "Not Specified for 0": { + senderKey: s.TestPrivKeys[0], + signKey: s.TestPrivKeys[0], + selectedAuthenticator: []uint64{}, + shouldPass: false, + }, + "Not Specified for 1": { + senderKey: s.TestPrivKeys[0], + signKey: s.TestPrivKeys[1], + selectedAuthenticator: []uint64{}, + shouldPass: false, + }, + "Bad selection": { + senderKey: s.TestPrivKeys[0], + signKey: s.TestPrivKeys[0], + selectedAuthenticator: []uint64{3}, + shouldPass: false, + }, + } + + for name, tc := range testCases { + s.Run(name, func() { + tx, _ := GenTx( + s.Ctx, + s.EncodingConfig.TxConfig, + []sdk.Msg{ + testMsg1, + }, + feeCoins, + 300000, + s.Ctx.ChainID(), + []uint64{6}, + []uint64{0}, + []cryptotypes.PrivKey{ + tc.senderKey, + }, + []cryptotypes.PrivKey{ + tc.signKey, + }, + tc.selectedAuthenticator, + ) + + anteHandler := sdk.ChainAnteDecorators(s.AuthenticatorDecorator) + _, err := anteHandler(s.Ctx.WithGasMeter(storetypes.NewGasMeter(300000)), tx, false) + + if tc.shouldPass { + s.Require().NoError(err, "Expected to pass but got error") + } else { + s.Require().Error(err, "Expected to fail but got no error") + } + }) + } +} + +// GenTx generates a signed mock transaction. +func GenTx( + ctx sdk.Context, + gen client.TxConfig, + msgs []sdk.Msg, + feeAmt sdk.Coins, + gas uint64, + chainID string, + accNums, accSeqs []uint64, + signers, signatures []cryptotypes.PrivKey, + selectedAuthenticators []uint64, +) (sdk.Tx, error) { + sigs := make([]signing.SignatureV2, len(signers)) + + // create a random length memo + r := rand.New(rand.NewSource(time.Now().UnixNano())) + memo := simulation.RandStringOfLength(r, simulation.RandIntBetween(r, 0, 100)) + signMode, err := authsigning.APISignModeToInternal(gen.SignModeHandler().DefaultMode()) + if err != nil { + return nil, err + } + + // 1st round: set SignatureV2 with empty signatures, to set correct + // signer infos. + for i, p := range signers { + sigs[i] = signing.SignatureV2{ + PubKey: p.PubKey(), + Data: &signing.SingleSignatureData{ + SignMode: signMode, + }, + Sequence: accSeqs[i], + } + } + + baseTxBuilder := gen.NewTxBuilder() + + txBuilder, ok := baseTxBuilder.(authtx.ExtensionOptionsTxBuilder) + if !ok { + return nil, fmt.Errorf("expected authtx.ExtensionOptionsTxBuilder, got %T", baseTxBuilder) + } + if len(selectedAuthenticators) > 0 { + value, err := codectypes.NewAnyWithValue(&types.TxExtension{ + SelectedAuthenticators: selectedAuthenticators, + }) + if err != nil { + return nil, err + } + txBuilder.SetNonCriticalExtensionOptions(value) + } + + err = txBuilder.SetMsgs(msgs...) + if err != nil { + return nil, err + } + err = txBuilder.SetSignatures(sigs...) + if err != nil { + return nil, err + } + txBuilder.SetMemo(memo) + txBuilder.SetFeeAmount(feeAmt) + txBuilder.SetGasLimit(gas) + + // 2nd round: once all signer infos are set, every signer can sign. + for i, p := range signatures { + signerData := authsigning.SignerData{ + ChainID: chainID, + AccountNumber: accNums[i], + Sequence: accSeqs[i], + } + signBytes, err := authsigning.GetSignBytesAdapter( + ctx, gen.SignModeHandler(), signMode, signerData, txBuilder.GetTx()) + if err != nil { + panic(err) + } + + sig, err := p.Sign(signBytes) + if err != nil { + panic(err) + } + sigs[i].Data.(*signing.SingleSignatureData).Signature = sig + err = txBuilder.SetSignatures(sigs...) + if err != nil { + panic(err) + } + } + + return txBuilder.GetTx(), nil +} diff --git a/protocol/x/accountplus/ante/circuit_breaker.go b/protocol/x/accountplus/ante/circuit_breaker.go new file mode 100644 index 0000000000..6de1a2282f --- /dev/null +++ b/protocol/x/accountplus/ante/circuit_breaker.go @@ -0,0 +1,46 @@ +package ante + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/keeper" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/lib" +) + +// CircuitBreakerDecorator routes transactions through appropriate ante handlers based on +// the IsCircuitBreakActive function. +type CircuitBreakerDecorator struct { + accountPlusKeeper *keeper.Keeper + authenticatorAnteHandlerFlow sdk.AnteHandler + originalAnteHandlerFlow sdk.AnteHandler +} + +// NewCircuitBreakerDecorator creates a new instance of CircuitBreakerDecorator with the provided parameters. +func NewCircuitBreakerDecorator( + accountPlusKeeper *keeper.Keeper, + auth sdk.AnteHandler, + classic sdk.AnteHandler, +) CircuitBreakerDecorator { + return CircuitBreakerDecorator{ + accountPlusKeeper: accountPlusKeeper, + authenticatorAnteHandlerFlow: auth, + originalAnteHandlerFlow: classic, + } +} + +// AnteHandle checks if a tx is a smart account tx and routes it through the correct series of ante handlers. +func (ad CircuitBreakerDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (newCtx sdk.Context, err error) { + // Check that the authenticator flow is active + if specified, _ := lib.HasSelectedAuthenticatorTxExtensionSpecified(tx, ad.accountPlusKeeper); specified { + // Return and call the AnteHandle function on all the authenticator decorators. + return ad.authenticatorAnteHandlerFlow(ctx, tx, simulate) + } + + // Return and call the AnteHandle function on all the original decorators. + return ad.originalAnteHandlerFlow(ctx, tx, simulate) +} diff --git a/protocol/x/accountplus/ante/circuit_breaker_test.go b/protocol/x/accountplus/ante/circuit_breaker_test.go new file mode 100644 index 0000000000..caa1bdabe8 --- /dev/null +++ b/protocol/x/accountplus/ante/circuit_breaker_test.go @@ -0,0 +1,194 @@ +package ante_test + +import ( + "encoding/hex" + "fmt" + "math/rand" + "os" + "testing" + + "github.com/cometbft/cometbft/types" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/dydxprotocol/v4-chain/protocol/app/config" + testapp "github.com/dydxprotocol/v4-chain/protocol/testutil/app" + "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" + + "github.com/stretchr/testify/suite" + + "github.com/dydxprotocol/v4-chain/protocol/app" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/ante" +) + +// AuthenticatorCircuitBreakerAnteSuite is a test suite for the authenticator and CircuitBreaker AnteDecorator. +type AuthenticatorCircuitBreakerAnteSuite struct { + suite.Suite + + tApp *testapp.TestApp + Ctx sdk.Context + EncodingConfig app.EncodingConfig + AuthenticatorDecorator ante.AuthenticatorDecorator + TestKeys []string + TestAccAddress []sdk.AccAddress + TestPrivKeys []*secp256k1.PrivKey + HomeDir string +} + +// TestAuthenticatorCircuitBreakerAnteSuite runs the test suite for the authenticator and CircuitBreaker AnteDecorator. +func TestAuthenticatorCircuitBreakerAnteSuite(t *testing.T) { + suite.Run(t, new(AuthenticatorCircuitBreakerAnteSuite)) +} + +// SetupTest initializes the test data and prepares the test environment. +func (s *AuthenticatorCircuitBreakerAnteSuite) SetupTest() { + // Test data for authenticator signature verification + TestKeys := []string{ + "6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159", + "0dd4d1506e18a5712080708c338eb51ecf2afdceae01e8162e890b126ac190fe", + "49006a359803f0602a7ec521df88bf5527579da79112bb71f285dd3e7d438033", + "05d2f57e30fb44835da1cad5274cefd4c80f6652c425fb9e6cc9c6749126497c", + "f98d0b79c0cc9805b905bfc5104f31293a270e60c6fc613a037eeb484fddb974", + } + + // Set up test accounts + accounts := make([]sdk.AccountI, 0) + for _, key := range TestKeys { + bz, _ := hex.DecodeString(key) + priv := &secp256k1.PrivKey{Key: bz} + + // Add the test private keys to an array for later use + s.TestPrivKeys = append(s.TestPrivKeys, priv) + + // Generate an account address from the public key + accAddress := sdk.AccAddress(priv.PubKey().Address()) + accounts = append( + accounts, + authtypes.NewBaseAccount(accAddress, priv.PubKey(), 0, 0), + ) + + // Add the test accounts' addresses to an array for later use + s.TestAccAddress = append(s.TestAccAddress, accAddress) + } + + // Initialize the Osmosis application + s.HomeDir = fmt.Sprintf("%d", rand.Int()) + s.tApp = testapp.NewTestAppBuilder(s.T()).WithGenesisDocFn(func() (genesis types.GenesisDoc) { + genesis = testapp.DefaultGenesis() + testapp.UpdateGenesisDocWithAppStateForModule( + &genesis, + func(genesisState *authtypes.GenesisState) { + for _, acct := range accounts { + genesisState.Accounts = append(genesisState.Accounts, codectypes.UnsafePackAny(acct)) + } + }, + ) + return genesis + }).Build() + s.Ctx = s.tApp.InitChain() + + s.EncodingConfig = app.GetEncodingConfig() +} + +func (s *AuthenticatorCircuitBreakerAnteSuite) TearDownTest() { + os.RemoveAll(s.HomeDir) +} + +// MockAnteDecorator used to test the CircuitBreaker flow +type MockAnteDecorator struct { + Called int +} + +// AnteHandle increments the ctx.Priority() differently based on what flow is active +func (m MockAnteDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler, +) (newCtx sdk.Context, err error) { + prio := ctx.Priority() + + if m.Called == 1 { + return ctx.WithPriority(prio + 1), nil + } else { + return ctx.WithPriority(prio + 2), nil + } +} + +// TestCircuitBreakerAnte verifies that the CircuitBreaker AnteDecorator functions correctly. +func (s *AuthenticatorCircuitBreakerAnteSuite) TestCircuitBreakerAnte() { + bech32Prefix := config.Bech32PrefixAccAddr + coins := sdk.Coins{sdk.NewInt64Coin(constants.TestNativeTokenDenom, 2500)} + + // Create test messages for signing + testMsg1 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[0]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + testMsg2 := &banktypes.MsgSend{ + FromAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + ToAddress: sdk.MustBech32ifyAddressBytes(bech32Prefix, s.TestAccAddress[1]), + Amount: coins, + } + feeCoins := constants.TestFeeCoins_5Cents + + // Generate a test transaction + tx, _ := GenTx(s.Ctx, s.EncodingConfig.TxConfig, []sdk.Msg{ + testMsg1, + testMsg2, + }, feeCoins, 300000, "", []uint64{0, 0}, []uint64{0, 0}, []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + s.TestPrivKeys[1], + }, []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + s.TestPrivKeys[1], + }, []uint64{}) + + mockTestClassic := MockAnteDecorator{Called: 1} + mockTestAuthenticator := MockAnteDecorator{Called: 0} + + // Create a CircuitBreaker AnteDecorator + cbd := ante.NewCircuitBreakerDecorator( + &s.tApp.App.AccountPlusKeeper, + sdk.ChainAnteDecorators(mockTestAuthenticator), + sdk.ChainAnteDecorators(mockTestClassic), + ) + anteHandler := sdk.ChainAnteDecorators(cbd) + + // Deactivate smart accounts + params := s.tApp.App.AccountPlusKeeper.GetParams(s.Ctx) + params.IsSmartAccountActive = false + s.tApp.App.AccountPlusKeeper.SetParams(s.Ctx, params) + + // Here we test when smart accounts are deactivated + ctx, err := anteHandler(s.Ctx, tx, false) + s.Require().NoError(err) + s.Require().Equal(int64(1), ctx.Priority(), "Should have disabled the full authentication flow") + + // Reactivate smart accounts + params = s.tApp.App.AccountPlusKeeper.GetParams(ctx) + params.IsSmartAccountActive = true + s.tApp.App.AccountPlusKeeper.SetParams(ctx, params) + + // Here we test when smart accounts are active and there is not selected authenticator + ctx, err = anteHandler(ctx, tx, false) + s.Require().Equal(int64(2), ctx.Priority(), "Will only go this way when a TxExtension is not included in the tx") + s.Require().NoError(err) + + // Generate a test transaction with a selected authenticator + tx, _ = GenTx(s.Ctx, s.EncodingConfig.TxConfig, []sdk.Msg{ + testMsg1, + testMsg2, + }, feeCoins, 300000, "", []uint64{0, 0}, []uint64{0, 0}, []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + s.TestPrivKeys[1], + }, []cryptotypes.PrivKey{ + s.TestPrivKeys[0], + s.TestPrivKeys[1], + }, []uint64{1}) + + // Test is smart accounts are active and the authenticator flow is selected + ctx, err = anteHandler(ctx, tx, false) + s.Require().NoError(err) + s.Require().Equal(int64(4), ctx.Priority(), "Should have used the full authentication flow") +} diff --git a/protocol/x/accountplus/keeper/authenticators.go b/protocol/x/accountplus/keeper/authenticators.go index 4d604206c1..99a04e4d48 100644 --- a/protocol/x/accountplus/keeper/authenticators.go +++ b/protocol/x/accountplus/keeper/authenticators.go @@ -6,8 +6,13 @@ import ( "cosmossdk.io/errors" "cosmossdk.io/store/prefix" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/telemetry" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" gogotypes "github.com/cosmos/gogoproto/types" + "github.com/dydxprotocol/v4-chain/protocol/lib/metrics" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/authenticator" "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/types" ) @@ -106,3 +111,104 @@ func (k Keeper) SetNextAuthenticatorId(ctx sdk.Context, authenticatorId uint64) store := ctx.KVStore(k.storeKey) store.Set([]byte(types.AuthenticatorIdKeyPrefix), b) } + +// GetAuthenticatorExtension unpacks the extension for the transaction, this is used with transactions specify +// an authenticator to use +func (k Keeper) GetAuthenticatorExtension(exts []*codectypes.Any) types.AuthenticatorTxOptions { + var authExtension types.AuthenticatorTxOptions + for _, ext := range exts { + err := k.cdc.UnpackAny(ext, &authExtension) + if err == nil { + return authExtension + } + } + return nil +} + +// GetSelectedAuthenticatorData gets all authenticators from an account +// from the store, the data is prefixed by 2| +func (k Keeper) GetSelectedAuthenticatorData( + ctx sdk.Context, + account sdk.AccAddress, + selectedAuthenticator int, +) (*types.AccountAuthenticator, error) { + store := prefix.NewStore( + ctx.KVStore(k.storeKey), + []byte(types.AuthenticatorKeyPrefix), + ) + bz := store.Get(types.KeyAccountId(account, uint64(selectedAuthenticator))) + if bz == nil { + return &types.AccountAuthenticator{}, errors.Wrap( + sdkerrors.ErrInvalidRequest, + fmt.Sprintf("authenticator %d not found for account %s", selectedAuthenticator, account), + ) + } + authenticatorFromStore, err := k.unmarshalAccountAuthenticator(bz) + if err != nil { + return &types.AccountAuthenticator{}, err + } + + return authenticatorFromStore, nil +} + +// GetInitializedAuthenticatorForAccount returns a single initialized authenticator for the account. +// It fetches the authenticator data from the store, gets the authenticator struct from the manager, +// then calls initialize on the authenticator data +func (k Keeper) GetInitializedAuthenticatorForAccount( + ctx sdk.Context, + account sdk.AccAddress, + selectedAuthenticator int, +) (authenticator.InitializedAuthenticator, error) { + // Get the authenticator data from the store + authenticatorFromStore, err := k.GetSelectedAuthenticatorData(ctx, account, selectedAuthenticator) + if err != nil { + return authenticator.InitializedAuthenticator{}, err + } + + uninitializedAuthenticator := k.authenticatorManager.GetAuthenticatorByType(authenticatorFromStore.Type) + if uninitializedAuthenticator == nil { + // This should never happen, but if it does, it means that stored authenticator is not registered + // or somehow the registered authenticator was removed / malformed + telemetry.IncrCounter(1, metrics.MissingRegisteredAuthenticator) + k.Logger(ctx).Error( + "account asscoicated authenticator not registered in manager", + "type", authenticatorFromStore.Type, + "id", selectedAuthenticator, + ) + + return authenticator.InitializedAuthenticator{}, + errors.Wrapf( + sdkerrors.ErrLogic, + "authenticator id %d failed to initialize, authenticator type %s not registered in manager", + selectedAuthenticator, authenticatorFromStore.Type, + ) + } + // Ensure that initialization of each authenticator works as expected + // NOTE: Always return a concrete authenticator not a pointer, do not modify in place + // NOTE: The authenticator manager returns a struct that is reused + initializedAuthenticator, err := uninitializedAuthenticator.Initialize(authenticatorFromStore.Config) + if err != nil || initializedAuthenticator == nil { + return authenticator.InitializedAuthenticator{}, + errors.Wrapf(err, + "authenticator %d with type %s failed to initialize", + selectedAuthenticator, authenticatorFromStore.Type, + ) + } + + finalAuthenticator := authenticator.InitializedAuthenticator{ + Id: authenticatorFromStore.Id, + Authenticator: initializedAuthenticator, + } + + return finalAuthenticator, nil +} + +// unmarshalAccountAuthenticator is used to unmarshal the AccountAuthenticator from the store +func (k Keeper) unmarshalAccountAuthenticator(bz []byte) (*types.AccountAuthenticator, error) { + var accountAuthenticator types.AccountAuthenticator + err := k.cdc.Unmarshal(bz, &accountAuthenticator) + if err != nil { + return &types.AccountAuthenticator{}, errors.Wrap(err, "failed to unmarshal account authenticator") + } + return &accountAuthenticator, nil +} diff --git a/protocol/x/accountplus/lib/lib.go b/protocol/x/accountplus/lib/lib.go new file mode 100644 index 0000000000..17b1fb0ab1 --- /dev/null +++ b/protocol/x/accountplus/lib/lib.go @@ -0,0 +1,30 @@ +package lib + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/keeper" + "github.com/dydxprotocol/v4-chain/protocol/x/accountplus/types" +) + +// HasSelectedAuthenticatorTxExtensionSpecified checks to see if the transaction has the correct +// extension, it returns false if we continue to the authenticator flow. +func HasSelectedAuthenticatorTxExtensionSpecified( + tx sdk.Tx, + accountPlusKeeper *keeper.Keeper, +) (bool, types.AuthenticatorTxOptions) { + extTx, ok := tx.(authante.HasExtensionOptionsTx) + if !ok { + return false, nil + } + + // Get the selected authenticator options from the transaction. + txOptions := accountPlusKeeper.GetAuthenticatorExtension(extTx.GetNonCriticalExtensionOptions()) + + // Check if authenticator transaction options are present and there is at least 1 selected. + if txOptions == nil || len(txOptions.GetSelectedAuthenticators()) < 1 { + return false, nil + } + + return true, txOptions +} diff --git a/protocol/x/accountplus/types/codec.go b/protocol/x/accountplus/types/codec.go index 994545c1e9..cbaf4e039b 100644 --- a/protocol/x/accountplus/types/codec.go +++ b/protocol/x/accountplus/types/codec.go @@ -3,11 +3,25 @@ package types import ( "github.com/cosmos/cosmos-sdk/codec" cdctypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/types/msgservice" + "github.com/cosmos/cosmos-sdk/types/tx" ) +// AuthenticatorTxOptions +type AuthenticatorTxOptions interface { + GetSelectedAuthenticators() []uint64 +} + func RegisterCodec(cdc *codec.LegacyAmino) {} func RegisterInterfaces(registry cdctypes.InterfaceRegistry) { + registry.RegisterImplementations((*tx.TxExtensionOptionI)(nil), &TxExtension{}) + + registry.RegisterImplementations( + (*AuthenticatorTxOptions)(nil), + &TxExtension{}, + ) + msgservice.RegisterMsgServiceDesc(registry, &_Msg_serviceDesc) } var ( diff --git a/protocol/x/accountplus/types/errors.go b/protocol/x/accountplus/types/errors.go index 48ad01cbd6..ecbce9f0bc 100644 --- a/protocol/x/accountplus/types/errors.go +++ b/protocol/x/accountplus/types/errors.go @@ -18,4 +18,9 @@ var ( 3, "Authenticator data exceeds maximum length", ) + ErrSmartAccountNotActive = errorsmod.Register( + ModuleName, + 4, + "Smart account is not active", + ) )