From 43ef6391af8ced8a938e6ce5a436a6d7834cb734 Mon Sep 17 00:00:00 2001 From: korayakpinar Date: Sat, 14 Mar 2026 00:52:36 +0300 Subject: [PATCH 1/2] feat: custom decorators implemented for mina sig verification --- app/ante/handler.go | 91 +++++++ app/ante/handler_test.go | 180 +++++++++++++ app/ante/mina_verifier.go | 182 +++++++++++++ app/ante/mina_verifier_test.go | 250 ++++++++++++++++++ app/ante/routed_decorators_test.go | 124 +++++++++ app/ante/routed_setpubkey.go | 32 +++ app/ante/routed_siggascost.go | 76 ++++++ app/ante/routed_sigverify.go | 48 ++++ app/ante/routed_validate_sigcount.go | 86 ++++++ app/ante/test_helpers_test.go | 18 ++ app/ante/tx_mode.go | 139 ++++++++++ app/ante/tx_mode_test.go | 131 ++++++++++ app/ante/types/codec.go | 14 + app/ante/types/tx_auth.pb.go | 331 ++++++++++++++++++++++++ app/app.go | 29 ++- app/app_config.go | 2 +- cmd/pulsard/cmd/root.go | 3 + docs/static/openapi.json | 2 +- go.mod | 2 +- proto/pulsarchain/ante/v1/tx_auth.proto | 23 ++ 20 files changed, 1759 insertions(+), 4 deletions(-) create mode 100644 app/ante/handler.go create mode 100644 app/ante/handler_test.go create mode 100644 app/ante/mina_verifier.go create mode 100644 app/ante/mina_verifier_test.go create mode 100644 app/ante/routed_decorators_test.go create mode 100644 app/ante/routed_setpubkey.go create mode 100644 app/ante/routed_siggascost.go create mode 100644 app/ante/routed_sigverify.go create mode 100644 app/ante/routed_validate_sigcount.go create mode 100644 app/ante/test_helpers_test.go create mode 100644 app/ante/tx_mode.go create mode 100644 app/ante/tx_mode_test.go create mode 100644 app/ante/types/codec.go create mode 100644 app/ante/types/tx_auth.pb.go create mode 100644 proto/pulsarchain/ante/v1/tx_auth.proto diff --git a/app/ante/handler.go b/app/ante/handler.go new file mode 100644 index 00000000..7e38baae --- /dev/null +++ b/app/ante/handler.go @@ -0,0 +1,91 @@ +package ante + +import ( + errorsmod "cosmossdk.io/errors" + "cosmossdk.io/log" + storetypes "cosmossdk.io/store/types" + txsigning "cosmossdk.io/x/tx/signing" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + signing "github.com/cosmos/cosmos-sdk/types/tx/signing" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" +) + +// HandlerOptions are the options required for constructing the app ante handler. +type HandlerOptions struct { + AccountKeeper authante.AccountKeeper + BankKeeper authtypes.BankKeeper + ExtensionOptionChecker authante.ExtensionOptionChecker + FeegrantKeeper authante.FeegrantKeeper + SignModeHandler *txsigning.HandlerMap + SigGasConsumer func(meter storetypes.GasMeter, sig signing.SignatureV2, params authtypes.Params) error + TxFeeChecker authante.TxFeeChecker + SigVerifyOptions []authante.SigVerificationDecoratorOption + MinaAddressResolver MinaAddressResolver + MinaNetworkID string + Logger log.Logger +} + +// NewAnteHandler returns the chain ante handler. +func NewAnteHandler(options HandlerOptions) (sdk.AnteHandler, error) { + if options.AccountKeeper == nil { + return nil, errorsmod.Wrap(sdkerrors.ErrLogic, "account keeper is required for ante builder") + } + + if options.BankKeeper == nil { + return nil, errorsmod.Wrap(sdkerrors.ErrLogic, "bank keeper is required for ante builder") + } + + if options.SignModeHandler == nil { + return nil, errorsmod.Wrap(sdkerrors.ErrLogic, "sign mode handler is required for ante builder") + } + + if options.MinaAddressResolver == nil { + return nil, errorsmod.Wrap(sdkerrors.ErrLogic, "mina address resolver is required for ante builder") + } + + if options.MinaNetworkID == "" { + return nil, errorsmod.Wrap(sdkerrors.ErrLogic, "mina network ID is required for ante builder") + } + + if options.Logger == nil { + return nil, errorsmod.Wrap(sdkerrors.ErrLogic, "logger is required for ante builder") + } + + cosmosSetPubKey := authante.NewSetPubKeyDecorator(options.AccountKeeper) + cosmosValidateSigCount := authante.NewValidateSigCountDecorator(options.AccountKeeper) + cosmosSigGasConsume := authante.NewSigGasConsumeDecorator(options.AccountKeeper, options.SigGasConsumer) + cosmosSigVerify := authante.NewSigVerificationDecorator( + options.AccountKeeper, + options.SignModeHandler, + options.SigVerifyOptions..., + ) + + minaVerifier := NewMinaVerifier( + options.MinaAddressResolver, + options.AccountKeeper, + options.SignModeHandler, + options.MinaNetworkID, + options.Logger, + ) + + anteDecorators := []sdk.AnteDecorator{ + authante.NewSetUpContextDecorator(), + authante.NewExtensionOptionsDecorator(NewTxAuthExtensionOptionChecker(options.ExtensionOptionChecker)), + authante.NewValidateBasicDecorator(), + authante.NewTxTimeoutHeightDecorator(), + authante.NewValidateMemoDecorator(options.AccountKeeper), + authante.NewConsumeGasForTxSizeDecorator(options.AccountKeeper), + authante.NewDeductFeeDecorator(options.AccountKeeper, options.BankKeeper, options.FeegrantKeeper, options.TxFeeChecker), + NewTxAuthModeDecorator(), + NewRoutedSetPubKeyDecorator(cosmosSetPubKey), + NewRoutedValidateSigCountDecorator(options.AccountKeeper, cosmosValidateSigCount), + NewRoutedSigGasConsumeDecorator(options.AccountKeeper, cosmosSigGasConsume), + NewRoutedSigVerificationDecorator(cosmosSigVerify, minaVerifier), + authante.NewIncrementSequenceDecorator(options.AccountKeeper), + } + + return sdk.ChainAnteDecorators(anteDecorators...), nil +} diff --git a/app/ante/handler_test.go b/app/ante/handler_test.go new file mode 100644 index 00000000..60caef31 --- /dev/null +++ b/app/ante/handler_test.go @@ -0,0 +1,180 @@ +package ante_test + +import ( + "context" + "testing" + "time" + + "cosmossdk.io/core/address" + "cosmossdk.io/log" + txsigning "cosmossdk.io/x/tx/signing" + sdk "github.com/cosmos/cosmos-sdk/types" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/stretchr/testify/require" + + appante "github.com/node101-io/pulsar-chain/app/ante" +) + +type stubAccountKeeper struct{} + +func (stubAccountKeeper) GetParams(context.Context) authtypes.Params { + return authtypes.Params{} +} + +func (stubAccountKeeper) GetAccount(context.Context, sdk.AccAddress) sdk.AccountI { + return nil +} + +func (stubAccountKeeper) SetAccount(context.Context, sdk.AccountI) {} + +func (stubAccountKeeper) GetModuleAddress(string) sdk.AccAddress { + return nil +} + +func (stubAccountKeeper) AddressCodec() address.Codec { + return nil +} + +func (stubAccountKeeper) UnorderedTransactionsEnabled() bool { + return false +} + +func (stubAccountKeeper) RemoveExpiredUnorderedNonces(sdk.Context) error { + return nil +} + +func (stubAccountKeeper) TryAddUnorderedNonce(sdk.Context, []byte, time.Time) error { + return nil +} + +type stubBankKeeper struct{} + +func (stubBankKeeper) IsSendEnabledCoins(context.Context, ...sdk.Coin) error { + return nil +} + +func (stubBankKeeper) SendCoins(context.Context, sdk.AccAddress, sdk.AccAddress, sdk.Coins) error { + return nil +} + +func (stubBankKeeper) SendCoinsFromAccountToModule(context.Context, sdk.AccAddress, string, sdk.Coins) error { + return nil +} + +var ( + _ authante.AccountKeeper = stubAccountKeeper{} + _ authtypes.BankKeeper = stubBankKeeper{} +) + +type stubMinaAddressResolver struct{} + +func (stubMinaAddressResolver) GetCosmosToMina(context.Context, []byte) ([]byte, error) { + return nil, nil +} + +func TestNewAnteHandler(t *testing.T) { + t.Parallel() + + anteHandler, err := appante.NewAnteHandler(appante.HandlerOptions{ + AccountKeeper: stubAccountKeeper{}, + BankKeeper: stubBankKeeper{}, + SignModeHandler: &txsigning.HandlerMap{}, + MinaAddressResolver: stubMinaAddressResolver{}, + MinaNetworkID: appante.DefaultMinaNetworkID, + Logger: log.NewNopLogger(), + }) + + require.NoError(t, err) + require.NotNil(t, anteHandler) +} + +func TestNewAnteHandlerRequiresAccountKeeper(t *testing.T) { + t.Parallel() + + anteHandler, err := appante.NewAnteHandler(appante.HandlerOptions{ + BankKeeper: stubBankKeeper{}, + SignModeHandler: &txsigning.HandlerMap{}, + MinaAddressResolver: stubMinaAddressResolver{}, + MinaNetworkID: appante.DefaultMinaNetworkID, + Logger: log.NewNopLogger(), + }) + + require.ErrorContains(t, err, "account keeper is required for ante builder") + require.Nil(t, anteHandler) +} + +func TestNewAnteHandlerRequiresBankKeeper(t *testing.T) { + t.Parallel() + + anteHandler, err := appante.NewAnteHandler(appante.HandlerOptions{ + AccountKeeper: stubAccountKeeper{}, + SignModeHandler: &txsigning.HandlerMap{}, + MinaAddressResolver: stubMinaAddressResolver{}, + MinaNetworkID: appante.DefaultMinaNetworkID, + Logger: log.NewNopLogger(), + }) + + require.ErrorContains(t, err, "bank keeper is required for ante builder") + require.Nil(t, anteHandler) +} + +func TestNewAnteHandlerRequiresSignModeHandler(t *testing.T) { + t.Parallel() + + anteHandler, err := appante.NewAnteHandler(appante.HandlerOptions{ + AccountKeeper: stubAccountKeeper{}, + BankKeeper: stubBankKeeper{}, + MinaAddressResolver: stubMinaAddressResolver{}, + MinaNetworkID: appante.DefaultMinaNetworkID, + Logger: log.NewNopLogger(), + }) + + require.ErrorContains(t, err, "sign mode handler is required for ante builder") + require.Nil(t, anteHandler) +} + +func TestNewAnteHandlerRequiresMinaAddressResolver(t *testing.T) { + t.Parallel() + + anteHandler, err := appante.NewAnteHandler(appante.HandlerOptions{ + AccountKeeper: stubAccountKeeper{}, + BankKeeper: stubBankKeeper{}, + SignModeHandler: &txsigning.HandlerMap{}, + MinaNetworkID: appante.DefaultMinaNetworkID, + Logger: log.NewNopLogger(), + }) + + require.ErrorContains(t, err, "mina address resolver is required for ante builder") + require.Nil(t, anteHandler) +} + +func TestNewAnteHandlerRequiresMinaNetworkID(t *testing.T) { + t.Parallel() + + anteHandler, err := appante.NewAnteHandler(appante.HandlerOptions{ + AccountKeeper: stubAccountKeeper{}, + BankKeeper: stubBankKeeper{}, + SignModeHandler: &txsigning.HandlerMap{}, + MinaAddressResolver: stubMinaAddressResolver{}, + Logger: log.NewNopLogger(), + }) + + require.ErrorContains(t, err, "mina network ID is required for ante builder") + require.Nil(t, anteHandler) +} + +func TestNewAnteHandlerRequiresLogger(t *testing.T) { + t.Parallel() + + anteHandler, err := appante.NewAnteHandler(appante.HandlerOptions{ + AccountKeeper: stubAccountKeeper{}, + BankKeeper: stubBankKeeper{}, + SignModeHandler: &txsigning.HandlerMap{}, + MinaAddressResolver: stubMinaAddressResolver{}, + MinaNetworkID: appante.DefaultMinaNetworkID, + }) + + require.ErrorContains(t, err, "logger is required for ante builder") + require.Nil(t, anteHandler) +} diff --git a/app/ante/mina_verifier.go b/app/ante/mina_verifier.go new file mode 100644 index 00000000..cff80936 --- /dev/null +++ b/app/ante/mina_verifier.go @@ -0,0 +1,182 @@ +package ante + +import ( + "context" + + errorsmod "cosmossdk.io/errors" + "cosmossdk.io/log" + txsigning "cosmossdk.io/x/tx/signing" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + signing "github.com/cosmos/cosmos-sdk/types/tx/signing" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" + "github.com/node101-io/mina-signer-go/keys" + minasignature "github.com/node101-io/mina-signer-go/signature" +) + +// DefaultMinaNetworkID is the default Mina network used by the chain. +const DefaultMinaNetworkID = "devnet" + +// MinaAddressResolver resolves a signer address to a registered Mina address. +type MinaAddressResolver interface { + GetCosmosToMina(ctx context.Context, signerAddress []byte) ([]byte, error) +} + +// MinaVerifier verifies tx signatures using Mina cryptography. +type MinaVerifier struct { + addressResolver MinaAddressResolver + accountKeeper authante.AccountKeeper + signModeHandler *txsigning.HandlerMap + networkID string + logger log.Logger +} + +// NewMinaVerifier creates a new Mina signature verifier. +func NewMinaVerifier( + addressResolver MinaAddressResolver, + accountKeeper authante.AccountKeeper, + signModeHandler *txsigning.HandlerMap, + networkID string, + logger log.Logger, +) MinaVerifier { + return MinaVerifier{ + addressResolver: addressResolver, + accountKeeper: accountKeeper, + signModeHandler: signModeHandler, + networkID: networkID, + logger: logger, + } +} + +// VerifySignatures verifies signatures for a Mina-authenticated tx. +func (v MinaVerifier) VerifySignatures(ctx sdk.Context, tx sdk.Tx, simulate bool) error { + sigTx, ok := tx.(authsigning.Tx) + if !ok { + return errorsmod.Wrap(sdkerrors.ErrTxDecode, "invalid transaction type") + } + + if unorderedTx, ok := tx.(sdk.TxWithUnordered); ok && unorderedTx.GetUnordered() { + return errorsmod.Wrap(sdkerrors.ErrNotSupported, "unordered Mina transactions are not supported") + } + + sigs, err := sigTx.GetSignaturesV2() + if err != nil { + return err + } + + signers, err := sigTx.GetSigners() + if err != nil { + return err + } + + if len(sigs) != len(signers) { + return errorsmod.Wrapf( + sdkerrors.ErrUnauthorized, + "invalid number of signer; expected: %d, got %d", + len(signers), + len(sigs), + ) + } + + for i, sig := range sigs { + acc, err := authante.GetSignerAcc(ctx, v.accountKeeper, signers[i]) + if err != nil { + return err + } + + if sig.Sequence != acc.GetSequence() { + return errorsmod.Wrapf( + sdkerrors.ErrWrongSequence, + "account sequence mismatch, expected %d, got %d", + acc.GetSequence(), + sig.Sequence, + ) + } + + if simulate || ctx.IsReCheckTx() || !ctx.IsSigverifyTx() { + v.logger.Debug("skipping Mina signature verification", "simulate", simulate, "recheck", ctx.IsReCheckTx()) + continue + } + + singleSig, ok := sig.Data.(*signing.SingleSignatureData) + if !ok { + return errorsmod.Wrap( + sdkerrors.ErrInvalidType, + "mina transactions require single signature data", + ) + } + + if err := v.verifySingleSignature(ctx, tx, acc, singleSig, sig.Sequence); err != nil { + return err + } + } + + return nil +} + +func (v MinaVerifier) verifySingleSignature( + ctx sdk.Context, + tx sdk.Tx, + account sdk.AccountI, + signatureData *signing.SingleSignatureData, + sequence uint64, +) error { + minaAddress, err := v.addressResolver.GetCosmosToMina(ctx, account.GetAddress()) + if err != nil { + return errorsmod.Wrapf( + sdkerrors.ErrUnauthorized, + "no Mina address registered for signer %s", + account.GetAddress().String(), + ) + } + + minaPubKey, err := new(keys.PublicKey).FromAddress(string(minaAddress)) + if err != nil { + return errorsmod.Wrapf( + sdkerrors.ErrInvalidPubKey, + "failed to parse Mina public key: %v", + err, + ) + } + + minaSignature := new(minasignature.Signature) + if err := minaSignature.UnmarshalBytes(signatureData.Signature); err != nil { + return errorsmod.Wrapf( + sdkerrors.ErrInvalidType, + "failed to parse Mina signature: %v", + err, + ) + } + + var accountNumber uint64 + if ctx.BlockHeight() > 0 { + accountNumber = account.GetAccountNumber() + } + + signBytes, err := authsigning.GetSignBytesAdapter( + ctx, + v.signModeHandler, + signatureData.SignMode, + authsigning.SignerData{ + Address: account.GetAddress().String(), + ChainID: ctx.ChainID(), + AccountNumber: accountNumber, + Sequence: sequence, + }, + tx, + ) + if err != nil { + return errorsmod.Wrapf( + sdkerrors.ErrInvalidType, + "failed to generate sign bytes: %v", + err, + ) + } + + if !minaPubKey.VerifyMessage(minaSignature, string(signBytes), v.networkID) { + return errorsmod.Wrap(sdkerrors.ErrUnauthorized, "Mina signature verification failed") + } + + return nil +} diff --git a/app/ante/mina_verifier_test.go b/app/ante/mina_verifier_test.go new file mode 100644 index 00000000..5f7246ec --- /dev/null +++ b/app/ante/mina_verifier_test.go @@ -0,0 +1,250 @@ +package ante + +import ( + "context" + "errors" + "testing" + "time" + + "cosmossdk.io/core/address" + "cosmossdk.io/log" + txsigning "cosmossdk.io/x/tx/signing" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/node101-io/mina-signer-go/keys" + "github.com/stretchr/testify/require" + protov2 "google.golang.org/protobuf/proto" +) + +type verifierAccountKeeper struct { + account sdk.AccountI + params authtypes.Params +} + +func (k verifierAccountKeeper) GetParams(context.Context) authtypes.Params { + if k.params.TxSigLimit == 0 { + return authtypes.DefaultParams() + } + + return k.params +} + +func (k verifierAccountKeeper) GetAccount(context.Context, sdk.AccAddress) sdk.AccountI { + return k.account +} + +func (k verifierAccountKeeper) SetAccount(context.Context, sdk.AccountI) {} + +func (k verifierAccountKeeper) GetModuleAddress(string) sdk.AccAddress { return nil } + +func (k verifierAccountKeeper) AddressCodec() address.Codec { return nil } + +func (k verifierAccountKeeper) UnorderedTransactionsEnabled() bool { return false } + +func (k verifierAccountKeeper) RemoveExpiredUnorderedNonces(sdk.Context) error { return nil } + +func (k verifierAccountKeeper) TryAddUnorderedNonce(sdk.Context, []byte, time.Time) error { + return nil +} + +type verifierResolver struct { + minaAddress []byte + err error + calls int +} + +func (r *verifierResolver) GetCosmosToMina(context.Context, []byte) ([]byte, error) { + r.calls++ + if r.err != nil { + return nil, r.err + } + + return r.minaAddress, nil +} + +type stubAuthTx struct { + signers [][]byte + sigs []signingtypes.SignatureV2 + unordered bool +} + +func (tx stubAuthTx) GetMsgs() []sdk.Msg { return nil } + +func (tx stubAuthTx) GetMsgsV2() ([]protov2.Message, error) { return nil, nil } + +func (tx stubAuthTx) GetSigners() ([][]byte, error) { return tx.signers, nil } + +func (tx stubAuthTx) GetPubKeys() ([]cryptotypes.PubKey, error) { return nil, nil } + +func (tx stubAuthTx) GetSignaturesV2() ([]signingtypes.SignatureV2, error) { return tx.sigs, nil } + +func (tx stubAuthTx) GetMemo() string { return "" } + +func (tx stubAuthTx) GetTimeoutHeight() uint64 { return 0 } + +func (tx stubAuthTx) GetTimeoutTimeStamp() time.Time { return time.Time{} } + +func (tx stubAuthTx) GetUnordered() bool { return tx.unordered } + +func (tx stubAuthTx) ValidateBasic() error { return nil } + +func (tx stubAuthTx) GetGas() uint64 { return 0 } + +func (tx stubAuthTx) GetFee() sdk.Coins { return nil } + +func (tx stubAuthTx) FeePayer() []byte { return nil } + +func (tx stubAuthTx) FeeGranter() []byte { return nil } + +func newVerifierForTest( + account sdk.AccountI, + resolver *verifierResolver, +) MinaVerifier { + return NewMinaVerifier( + resolver, + verifierAccountKeeper{account: account}, + &txsigning.HandlerMap{}, + DefaultMinaNetworkID, + log.NewNopLogger(), + ) +} + +func TestMinaVerifierRejectsUnorderedTransactions(t *testing.T) { + t.Parallel() + + address := sdk.AccAddress([]byte("verifier-address-001")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + verifier := newVerifierForTest(account, &verifierResolver{}) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + unordered: true, + }, false) + + require.ErrorContains(t, err, "unordered Mina transactions are not supported") +} + +func TestMinaVerifierRejectsWrongSequence(t *testing.T) { + t.Parallel() + + address := sdk.AccAddress([]byte("verifier-address-002")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + verifier := newVerifierForTest(account, &verifierResolver{}) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + signers: [][]byte{address}, + sigs: []signingtypes.SignatureV2{ + { + Sequence: 8, + Data: &signingtypes.SingleSignatureData{ + SignMode: signingtypes.SignMode_SIGN_MODE_DIRECT, + Signature: []byte{1}, + }, + }, + }, + }, false) + + require.ErrorContains(t, err, "account sequence mismatch") +} + +func TestMinaVerifierRejectsNonSingleSignatureData(t *testing.T) { + t.Parallel() + + address := sdk.AccAddress([]byte("verifier-address-003")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + verifier := newVerifierForTest(account, &verifierResolver{}) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + signers: [][]byte{address}, + sigs: []signingtypes.SignatureV2{ + { + Sequence: 7, + Data: &signingtypes.MultiSignatureData{}, + }, + }, + }, false) + + require.ErrorContains(t, err, "mina transactions require single signature data") +} + +func TestMinaVerifierRejectsMissingRegistryEntry(t *testing.T) { + t.Parallel() + + address := sdk.AccAddress([]byte("verifier-address-004")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + resolver := &verifierResolver{err: errors.New("not found")} + verifier := newVerifierForTest(account, resolver) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + signers: [][]byte{address}, + sigs: []signingtypes.SignatureV2{ + { + Sequence: 7, + Data: &signingtypes.SingleSignatureData{ + SignMode: signingtypes.SignMode_SIGN_MODE_DIRECT, + Signature: []byte{1}, + }, + }, + }, + }, false) + + require.ErrorContains(t, err, "no Mina address registered for signer") + require.Equal(t, 1, resolver.calls) +} + +func TestMinaVerifierRejectsInvalidSignatureEncoding(t *testing.T) { + t.Parallel() + + address := sdk.AccAddress([]byte("verifier-address-005")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + + var seed [32]byte + seed[0] = 1 + minaAddress, err := keys.NewPrivateKeyFromBytes(seed).ToPublicKey().ToAddress() + require.NoError(t, err) + + resolver := &verifierResolver{minaAddress: []byte(minaAddress)} + verifier := newVerifierForTest(account, resolver) + + err = verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + signers: [][]byte{address}, + sigs: []signingtypes.SignatureV2{ + { + Sequence: 7, + Data: &signingtypes.SingleSignatureData{ + SignMode: signingtypes.SignMode_SIGN_MODE_DIRECT, + Signature: []byte{1}, + }, + }, + }, + }, false) + + require.ErrorContains(t, err, "failed to parse Mina signature") + require.Equal(t, 1, resolver.calls) +} + +func TestMinaVerifierSkipsCryptoVerificationDuringSimulation(t *testing.T) { + t.Parallel() + + address := sdk.AccAddress([]byte("verifier-address-006")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + resolver := &verifierResolver{err: errors.New("should not be called")} + verifier := newVerifierForTest(account, resolver) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + signers: [][]byte{address}, + sigs: []signingtypes.SignatureV2{ + { + Sequence: 7, + Data: &signingtypes.SingleSignatureData{ + SignMode: signingtypes.SignMode_SIGN_MODE_DIRECT, + Signature: []byte{1}, + }, + }, + }, + }, true) + + require.NoError(t, err) + require.Zero(t, resolver.calls) +} diff --git a/app/ante/routed_decorators_test.go b/app/ante/routed_decorators_test.go new file mode 100644 index 00000000..3e0bfe44 --- /dev/null +++ b/app/ante/routed_decorators_test.go @@ -0,0 +1,124 @@ +package ante + +import ( + "errors" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" +) + +type recordingDecorator struct { + calls int +} + +func (d *recordingDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (sdk.Context, error) { + d.calls++ + return next(ctx, tx, simulate) +} + +type recordingVerifier struct { + calls int + err error +} + +func (v *recordingVerifier) VerifySignatures(ctx sdk.Context, tx sdk.Tx, simulate bool) error { + v.calls++ + return v.err +} + +func TestRoutedSetPubKeyDecoratorRoutesToCosmosDecorator(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeCosmos) + delegate := &recordingDecorator{} + nextCalled := false + + _, err := NewRoutedSetPubKeyDecorator(delegate).AnteHandle(ctx, nil, false, func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }) + + require.NoError(t, err) + require.Equal(t, 1, delegate.calls) + require.True(t, nextCalled) +} + +func TestRoutedSetPubKeyDecoratorSkipsForMina(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + delegate := &recordingDecorator{} + nextCalled := false + + _, err := NewRoutedSetPubKeyDecorator(delegate).AnteHandle(ctx, nil, false, func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }) + + require.NoError(t, err) + require.Zero(t, delegate.calls) + require.True(t, nextCalled) +} + +func TestRoutedSigVerificationDecoratorRoutesToCosmosDecorator(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeCosmos) + delegate := &recordingDecorator{} + verifier := &recordingVerifier{} + nextCalled := false + + _, err := NewRoutedSigVerificationDecorator(delegate, verifier).AnteHandle(ctx, nil, false, func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }) + + require.NoError(t, err) + require.Equal(t, 1, delegate.calls) + require.Zero(t, verifier.calls) + require.True(t, nextCalled) +} + +func TestRoutedSigVerificationDecoratorRoutesToMinaVerifier(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + delegate := &recordingDecorator{} + verifier := &recordingVerifier{} + nextCalled := false + + _, err := NewRoutedSigVerificationDecorator(delegate, verifier).AnteHandle(ctx, nil, false, func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }) + + require.NoError(t, err) + require.Zero(t, delegate.calls) + require.Equal(t, 1, verifier.calls) + require.True(t, nextCalled) +} + +func TestRoutedSigVerificationDecoratorPropagatesVerifierErrors(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + expectedErr := errors.New("verify failed") + + _, err := NewRoutedSigVerificationDecorator(&recordingDecorator{}, &recordingVerifier{err: expectedErr}).AnteHandle( + ctx, + nil, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called when verifier fails") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, expectedErr) +} diff --git a/app/ante/routed_setpubkey.go b/app/ante/routed_setpubkey.go new file mode 100644 index 00000000..92857652 --- /dev/null +++ b/app/ante/routed_setpubkey.go @@ -0,0 +1,32 @@ +package ante + +import sdk "github.com/cosmos/cosmos-sdk/types" + +// RoutedSetPubKeyDecorator routes pubkey handling based on the resolved tx auth mode. +type RoutedSetPubKeyDecorator struct { + cosmosDecorator sdk.AnteDecorator +} + +// NewRoutedSetPubKeyDecorator creates a new routed set-pubkey decorator. +func NewRoutedSetPubKeyDecorator(cosmosDecorator sdk.AnteDecorator) RoutedSetPubKeyDecorator { + return RoutedSetPubKeyDecorator{cosmosDecorator: cosmosDecorator} +} + +// AnteHandle implements sdk.AnteDecorator. +func (d RoutedSetPubKeyDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (sdk.Context, error) { + mode, err := getOrResolveTxAuthMode(ctx, tx) + if err != nil { + return ctx, err + } + + if mode == TxAuthModeMina { + return next(ctx, tx, simulate) + } + + return d.cosmosDecorator.AnteHandle(ctx, tx, simulate, next) +} diff --git a/app/ante/routed_siggascost.go b/app/ante/routed_siggascost.go new file mode 100644 index 00000000..a5d4e822 --- /dev/null +++ b/app/ante/routed_siggascost.go @@ -0,0 +1,76 @@ +package ante + +import ( + errorsmod "cosmossdk.io/errors" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + signing "github.com/cosmos/cosmos-sdk/types/tx/signing" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" +) + +// RoutedSigGasConsumeDecorator routes signature gas accounting by tx auth mode. +type RoutedSigGasConsumeDecorator struct { + accountKeeper authante.AccountKeeper + cosmosDecorator sdk.AnteDecorator +} + +// NewRoutedSigGasConsumeDecorator creates a routed signature gas decorator. +func NewRoutedSigGasConsumeDecorator( + accountKeeper authante.AccountKeeper, + cosmosDecorator sdk.AnteDecorator, +) RoutedSigGasConsumeDecorator { + return RoutedSigGasConsumeDecorator{ + accountKeeper: accountKeeper, + cosmosDecorator: cosmosDecorator, + } +} + +// AnteHandle implements sdk.AnteDecorator. +func (d RoutedSigGasConsumeDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (sdk.Context, error) { + mode, err := getOrResolveTxAuthMode(ctx, tx) + if err != nil { + return ctx, err + } + + if mode != TxAuthModeMina { + return d.cosmosDecorator.AnteHandle(ctx, tx, simulate, next) + } + + sigTx, ok := tx.(authsigning.SigVerifiableTx) + if !ok { + return ctx, errorsmod.Wrap(sdkerrors.ErrTxDecode, "invalid transaction type") + } + + params := d.accountKeeper.GetParams(ctx) + sigs, err := sigTx.GetSignaturesV2() + if err != nil { + return ctx, err + } + + for _, sig := range sigs { + switch sig.Data.(type) { + case *signing.SingleSignatureData: + ctx.GasMeter().ConsumeGas(params.SigVerifyCostSecp256k1, "ante verify: mina") + case *signing.MultiSignatureData: + return ctx, errorsmod.Wrap( + sdkerrors.ErrInvalidType, + "mina transactions do not support multisig signatures", + ) + default: + return ctx, errorsmod.Wrapf( + sdkerrors.ErrInvalidType, + "unexpected signature data type %T", + sig.Data, + ) + } + } + + return next(ctx, tx, simulate) +} diff --git a/app/ante/routed_sigverify.go b/app/ante/routed_sigverify.go new file mode 100644 index 00000000..cbbfb804 --- /dev/null +++ b/app/ante/routed_sigverify.go @@ -0,0 +1,48 @@ +package ante + +import sdk "github.com/cosmos/cosmos-sdk/types" + +// MinaSignatureVerifier verifies Mina-authenticated tx signatures. +type MinaSignatureVerifier interface { + VerifySignatures(ctx sdk.Context, tx sdk.Tx, simulate bool) error +} + +// RoutedSigVerificationDecorator routes signature verification based on tx auth mode. +type RoutedSigVerificationDecorator struct { + cosmosDecorator sdk.AnteDecorator + minaVerifier MinaSignatureVerifier +} + +// NewRoutedSigVerificationDecorator creates a routed signature verification decorator. +func NewRoutedSigVerificationDecorator( + cosmosDecorator sdk.AnteDecorator, + minaVerifier MinaSignatureVerifier, +) RoutedSigVerificationDecorator { + return RoutedSigVerificationDecorator{ + cosmosDecorator: cosmosDecorator, + minaVerifier: minaVerifier, + } +} + +// AnteHandle implements sdk.AnteDecorator. +func (d RoutedSigVerificationDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (sdk.Context, error) { + mode, err := getOrResolveTxAuthMode(ctx, tx) + if err != nil { + return ctx, err + } + + if mode != TxAuthModeMina { + return d.cosmosDecorator.AnteHandle(ctx, tx, simulate, next) + } + + if err := d.minaVerifier.VerifySignatures(ctx, tx, simulate); err != nil { + return ctx, err + } + + return next(ctx, tx, simulate) +} diff --git a/app/ante/routed_validate_sigcount.go b/app/ante/routed_validate_sigcount.go new file mode 100644 index 00000000..806ce959 --- /dev/null +++ b/app/ante/routed_validate_sigcount.go @@ -0,0 +1,86 @@ +package ante + +import ( + errorsmod "cosmossdk.io/errors" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + signing "github.com/cosmos/cosmos-sdk/types/tx/signing" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" +) + +// RoutedValidateSigCountDecorator routes signature-count validation by tx auth mode. +type RoutedValidateSigCountDecorator struct { + accountKeeper authante.AccountKeeper + cosmosDecorator sdk.AnteDecorator +} + +// NewRoutedValidateSigCountDecorator creates a routed signature-count decorator. +func NewRoutedValidateSigCountDecorator( + accountKeeper authante.AccountKeeper, + cosmosDecorator sdk.AnteDecorator, +) RoutedValidateSigCountDecorator { + return RoutedValidateSigCountDecorator{ + accountKeeper: accountKeeper, + cosmosDecorator: cosmosDecorator, + } +} + +// AnteHandle implements sdk.AnteDecorator. +func (d RoutedValidateSigCountDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (sdk.Context, error) { + mode, err := getOrResolveTxAuthMode(ctx, tx) + if err != nil { + return ctx, err + } + + if mode != TxAuthModeMina { + return d.cosmosDecorator.AnteHandle(ctx, tx, simulate, next) + } + + sigTx, ok := tx.(authsigning.SigVerifiableTx) + if !ok { + return ctx, errorsmod.Wrap(sdkerrors.ErrTxDecode, "Tx must be a sigTx") + } + + params := d.accountKeeper.GetParams(ctx) + sigs, err := sigTx.GetSignaturesV2() + if err != nil { + return ctx, err + } + + sigCount := 0 + for _, sig := range sigs { + switch sig.Data.(type) { + case *signing.SingleSignatureData: + sigCount++ + case *signing.MultiSignatureData: + return ctx, errorsmod.Wrap( + sdkerrors.ErrInvalidType, + "mina transactions do not support multisig signatures", + ) + default: + return ctx, errorsmod.Wrapf( + sdkerrors.ErrInvalidType, + "unexpected signature data type %T", + sig.Data, + ) + } + + if uint64(sigCount) > params.TxSigLimit { + return ctx, errorsmod.Wrapf( + sdkerrors.ErrTooManySignatures, + "signatures: %d, limit: %d", + sigCount, + params.TxSigLimit, + ) + } + } + + return next(ctx, tx, simulate) +} diff --git a/app/ante/test_helpers_test.go b/app/ante/test_helpers_test.go new file mode 100644 index 00000000..a62cd9d2 --- /dev/null +++ b/app/ante/test_helpers_test.go @@ -0,0 +1,18 @@ +package ante + +import ( + "testing" + + "cosmossdk.io/log" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +func newTestSDKContext(tb testing.TB) sdk.Context { + tb.Helper() + + return sdk.NewContext(nil, cmtproto.Header{ + ChainID: "pulsar-test-1", + Height: 1, + }, false, log.NewNopLogger()) +} diff --git a/app/ante/tx_mode.go b/app/ante/tx_mode.go new file mode 100644 index 00000000..f3e76744 --- /dev/null +++ b/app/ante/tx_mode.go @@ -0,0 +1,139 @@ +package ante + +import ( + errorsmod "cosmossdk.io/errors" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + gogoproto "github.com/cosmos/gogoproto/proto" + + antetypes "github.com/node101-io/pulsar-chain/app/ante/types" +) + +const txAuthModeExtensionTypeURL = "/pulsarchain.ante.v1.TxAuthModeExtension" + +type txAuthModeContextKey struct{} + +// TxAuthMode is the auth verification mode selected for a tx. +type TxAuthMode uint8 + +const ( + TxAuthModeCosmos TxAuthMode = iota + TxAuthModeMina +) + +// RegisterInterfaces registers ante extension interfaces. +func RegisterInterfaces(registrar codectypes.InterfaceRegistry) { + antetypes.RegisterInterfaces(registrar) +} + +// HasExtensionOptionsTx is the subset of tx functionality needed for auth mode resolution. +type HasExtensionOptionsTx interface { + GetExtensionOptions() []*codectypes.Any +} + +// NewTxAuthExtensionOptionChecker returns an extension checker that accepts the tx auth mode extension. +func NewTxAuthExtensionOptionChecker(additional authante.ExtensionOptionChecker) authante.ExtensionOptionChecker { + return func(extOption *codectypes.Any) bool { + if extOption != nil && extOption.TypeUrl == txAuthModeExtensionTypeURL { + return true + } + + if additional == nil { + return false + } + + return additional(extOption) + } +} + +// NewTxAuthModeDecorator resolves the tx auth mode once and stores it in context. +func NewTxAuthModeDecorator() TxAuthModeDecorator { + return TxAuthModeDecorator{} +} + +// TxAuthModeDecorator stores the resolved tx auth mode in context for downstream decorators. +type TxAuthModeDecorator struct{} + +// AnteHandle implements sdk.AnteDecorator. +func (d TxAuthModeDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (sdk.Context, error) { + mode, err := ResolveTxAuthMode(tx) + if err != nil { + return ctx, err + } + + return next(setTxAuthMode(ctx, mode), tx, simulate) +} + +// ResolveTxAuthMode resolves the tx auth mode from extension options. +func ResolveTxAuthMode(tx sdk.Tx) (TxAuthMode, error) { + txWithExtensions, ok := tx.(HasExtensionOptionsTx) + if !ok { + return TxAuthModeCosmos, nil + } + + var found *antetypes.TxAuthModeExtension + for _, extOption := range txWithExtensions.GetExtensionOptions() { + if extOption == nil || extOption.TypeUrl != txAuthModeExtensionTypeURL { + continue + } + + if found != nil { + return TxAuthModeCosmos, errorsmod.Wrap( + sdkerrors.ErrInvalidRequest, + "multiple tx auth mode extensions found", + ) + } + + found = &antetypes.TxAuthModeExtension{} + if err := gogoproto.Unmarshal(extOption.Value, found); err != nil { + return TxAuthModeCosmos, errorsmod.Wrapf( + sdkerrors.ErrTxDecode, + "invalid tx auth mode extension: %v", + err, + ) + } + } + + if found == nil { + return TxAuthModeCosmos, nil + } + + switch found.TxAuthMode { + case antetypes.TX_AUTH_MODE_COSMOS: + return TxAuthModeCosmos, nil + case antetypes.TX_AUTH_MODE_MINA: + return TxAuthModeMina, nil + default: + return TxAuthModeCosmos, errorsmod.Wrapf( + sdkerrors.ErrInvalidRequest, + "unsupported tx auth mode: %s", + found.TxAuthMode.String(), + ) + } +} + +func setTxAuthMode(ctx sdk.Context, mode TxAuthMode) sdk.Context { + return ctx.WithValue(txAuthModeContextKey{}, mode) +} + +// GetTxAuthMode returns the tx auth mode from context if already resolved. +func GetTxAuthMode(ctx sdk.Context) (TxAuthMode, bool) { + mode, ok := ctx.Value(txAuthModeContextKey{}).(TxAuthMode) + return mode, ok +} + +func getOrResolveTxAuthMode(ctx sdk.Context, tx sdk.Tx) (TxAuthMode, error) { + if mode, ok := GetTxAuthMode(ctx); ok { + return mode, nil + } + + return ResolveTxAuthMode(tx) +} diff --git a/app/ante/tx_mode_test.go b/app/ante/tx_mode_test.go new file mode 100644 index 00000000..9ac8c9bc --- /dev/null +++ b/app/ante/tx_mode_test.go @@ -0,0 +1,131 @@ +package ante + +import ( + "testing" + + "cosmossdk.io/log" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + gogoproto "github.com/cosmos/gogoproto/proto" + "github.com/stretchr/testify/require" + protov2 "google.golang.org/protobuf/proto" + + antetypes "github.com/node101-io/pulsar-chain/app/ante/types" +) + +type stubExtensionTx struct { + extensionOptions []*codectypes.Any +} + +func (tx stubExtensionTx) GetMsgs() []sdk.Msg { return nil } + +func (tx stubExtensionTx) GetMsgsV2() ([]protov2.Message, error) { return nil, nil } + +func (tx stubExtensionTx) GetExtensionOptions() []*codectypes.Any { + return tx.extensionOptions +} + +func mustTxAuthModeExtensionAny(t *testing.T, mode antetypes.TxAuthMode) *codectypes.Any { + t.Helper() + + bz, err := gogoproto.Marshal(&antetypes.TxAuthModeExtension{TxAuthMode: mode}) + require.NoError(t, err) + + return &codectypes.Any{ + TypeUrl: txAuthModeExtensionTypeURL, + Value: bz, + } +} + +func TestResolveTxAuthModeDefaultsToCosmos(t *testing.T) { + t.Parallel() + + mode, err := ResolveTxAuthMode(stubExtensionTx{}) + + require.NoError(t, err) + require.Equal(t, TxAuthModeCosmos, mode) +} + +func TestResolveTxAuthModeReadsMinaExtension(t *testing.T) { + t.Parallel() + + mode, err := ResolveTxAuthMode(stubExtensionTx{ + extensionOptions: []*codectypes.Any{ + mustTxAuthModeExtensionAny(t, antetypes.TX_AUTH_MODE_MINA), + }, + }) + + require.NoError(t, err) + require.Equal(t, TxAuthModeMina, mode) +} + +func TestResolveTxAuthModeRejectsMalformedExtension(t *testing.T) { + t.Parallel() + + mode, err := ResolveTxAuthMode(stubExtensionTx{ + extensionOptions: []*codectypes.Any{ + {TypeUrl: txAuthModeExtensionTypeURL, Value: []byte("not-proto")}, + }, + }) + + require.ErrorContains(t, err, "invalid tx auth mode extension") + require.Equal(t, TxAuthModeCosmos, mode) +} + +func TestResolveTxAuthModeRejectsMultipleExtensions(t *testing.T) { + t.Parallel() + + mode, err := ResolveTxAuthMode(stubExtensionTx{ + extensionOptions: []*codectypes.Any{ + mustTxAuthModeExtensionAny(t, antetypes.TX_AUTH_MODE_COSMOS), + mustTxAuthModeExtensionAny(t, antetypes.TX_AUTH_MODE_MINA), + }, + }) + + require.ErrorContains(t, err, "multiple tx auth mode extensions found") + require.Equal(t, TxAuthModeCosmos, mode) +} + +func TestNewTxAuthExtensionOptionCheckerAcceptsCustomType(t *testing.T) { + t.Parallel() + + delegated := false + checker := NewTxAuthExtensionOptionChecker(func(extOption *codectypes.Any) bool { + delegated = true + return extOption != nil && extOption.TypeUrl == "/example.Extension" + }) + + require.True(t, checker(mustTxAuthModeExtensionAny(t, antetypes.TX_AUTH_MODE_MINA))) + require.True(t, checker(&codectypes.Any{TypeUrl: "/example.Extension"})) + require.True(t, delegated) + require.False(t, checker(&codectypes.Any{TypeUrl: "/other.Extension"})) +} + +func TestTxAuthModeDecoratorStoresResolvedMode(t *testing.T) { + t.Parallel() + + ctx := newTestSDKContext(t) + tx := stubExtensionTx{ + extensionOptions: []*codectypes.Any{ + mustTxAuthModeExtensionAny(t, antetypes.TX_AUTH_MODE_MINA), + }, + } + + decorator := NewTxAuthModeDecorator() + nextCalled := false + + next := func(nextCtx sdk.Context, nextTx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + mode, ok := GetTxAuthMode(nextCtx) + require.True(t, ok) + require.Equal(t, TxAuthModeMina, mode) + require.Equal(t, tx, nextTx) + require.False(t, simulate) + return nextCtx.WithLogger(log.NewNopLogger()), nil + } + + _, err := decorator.AnteHandle(ctx, tx, false, next) + + require.NoError(t, err) + require.True(t, nextCalled) +} diff --git a/app/ante/types/codec.go b/app/ante/types/codec.go new file mode 100644 index 00000000..86e0c13f --- /dev/null +++ b/app/ante/types/codec.go @@ -0,0 +1,14 @@ +package types + +import ( + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + tx "github.com/cosmos/cosmos-sdk/types/tx" +) + +// RegisterInterfaces registers the tx auth mode extension as a tx extension option. +func RegisterInterfaces(registrar codectypes.InterfaceRegistry) { + registrar.RegisterImplementations( + (*tx.TxExtensionOptionI)(nil), + &TxAuthModeExtension{}, + ) +} diff --git a/app/ante/types/tx_auth.pb.go b/app/ante/types/tx_auth.pb.go new file mode 100644 index 00000000..8296908d --- /dev/null +++ b/app/ante/types/tx_auth.pb.go @@ -0,0 +1,331 @@ +// Code generated by protoc-gen-gogo. DO NOT EDIT. +// source: pulsarchain/ante/v1/tx_auth.proto + +package types + +import ( + fmt "fmt" + _ "github.com/cosmos/gogoproto/gogoproto" + proto "github.com/cosmos/gogoproto/proto" + io "io" + math "math" + math_bits "math/bits" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package + +// TxAuthMode determines which signature verification path a tx should use. +type TxAuthMode int32 + +const ( + TX_AUTH_MODE_UNSPECIFIED TxAuthMode = 0 + TX_AUTH_MODE_COSMOS TxAuthMode = 1 + TX_AUTH_MODE_MINA TxAuthMode = 2 +) + +var TxAuthMode_name = map[int32]string{ + 0: "TX_AUTH_MODE_UNSPECIFIED", + 1: "TX_AUTH_MODE_COSMOS", + 2: "TX_AUTH_MODE_MINA", +} + +var TxAuthMode_value = map[string]int32{ + "TX_AUTH_MODE_UNSPECIFIED": 0, + "TX_AUTH_MODE_COSMOS": 1, + "TX_AUTH_MODE_MINA": 2, +} + +func (x TxAuthMode) String() string { + return proto.EnumName(TxAuthMode_name, int32(x)) +} + +func (TxAuthMode) EnumDescriptor() ([]byte, []int) { + return fileDescriptor_5c6991cd595ed7d3, []int{0} +} + +// TxAuthModeExtension marks the authentication mode of a transaction. +type TxAuthModeExtension struct { + TxAuthMode TxAuthMode `protobuf:"varint,1,opt,name=tx_auth_mode,json=txAuthMode,proto3,enum=pulsarchain.ante.v1.TxAuthMode" json:"tx_auth_mode,omitempty"` +} + +func (m *TxAuthModeExtension) Reset() { *m = TxAuthModeExtension{} } +func (m *TxAuthModeExtension) String() string { return proto.CompactTextString(m) } +func (*TxAuthModeExtension) ProtoMessage() {} +func (*TxAuthModeExtension) Descriptor() ([]byte, []int) { + return fileDescriptor_5c6991cd595ed7d3, []int{0} +} +func (m *TxAuthModeExtension) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *TxAuthModeExtension) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_TxAuthModeExtension.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *TxAuthModeExtension) XXX_Merge(src proto.Message) { + xxx_messageInfo_TxAuthModeExtension.Merge(m, src) +} +func (m *TxAuthModeExtension) XXX_Size() int { + return m.Size() +} +func (m *TxAuthModeExtension) XXX_DiscardUnknown() { + xxx_messageInfo_TxAuthModeExtension.DiscardUnknown(m) +} + +var xxx_messageInfo_TxAuthModeExtension proto.InternalMessageInfo + +func init() { + proto.RegisterEnum("pulsarchain.ante.v1.TxAuthMode", TxAuthMode_name, TxAuthMode_value) + proto.RegisterType((*TxAuthModeExtension)(nil), "pulsarchain.ante.v1.TxAuthModeExtension") +} + +func init() { proto.RegisterFile("pulsarchain/ante/v1/tx_auth.proto", fileDescriptor_5c6991cd595ed7d3) } + +var fileDescriptor_5c6991cd595ed7d3 = []byte{ + // 281 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x52, 0x2c, 0x28, 0xcd, 0x29, + 0x4e, 0x2c, 0x4a, 0xce, 0x48, 0xcc, 0xcc, 0xd3, 0x4f, 0xcc, 0x2b, 0x49, 0xd5, 0x2f, 0x33, 0xd4, + 0x2f, 0xa9, 0x88, 0x4f, 0x2c, 0x2d, 0xc9, 0xd0, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x12, 0x46, + 0x52, 0xa2, 0x07, 0x52, 0xa2, 0x57, 0x66, 0x28, 0x25, 0x92, 0x9e, 0x9f, 0x9e, 0x0f, 0x96, 0xd7, + 0x07, 0xb1, 0x20, 0x4a, 0x95, 0xe2, 0xb8, 0x84, 0x43, 0x2a, 0x1c, 0x4b, 0x4b, 0x32, 0x7c, 0xf3, + 0x53, 0x52, 0x5d, 0x2b, 0x4a, 0x52, 0xf3, 0x8a, 0x33, 0xf3, 0xf3, 0x84, 0x1c, 0xb9, 0x78, 0xa0, + 0x46, 0xc6, 0xe7, 0xe6, 0xa7, 0xa4, 0x4a, 0x30, 0x2a, 0x30, 0x6a, 0xf0, 0x19, 0xc9, 0xeb, 0x61, + 0x31, 0x58, 0x0f, 0xa1, 0x3f, 0x88, 0xab, 0x04, 0xce, 0xb6, 0x62, 0xe9, 0x58, 0x20, 0xcf, 0xa0, + 0x95, 0xc0, 0xc5, 0x85, 0x90, 0x17, 0x92, 0xe1, 0x92, 0x08, 0x89, 0x88, 0x77, 0x0c, 0x0d, 0xf1, + 0x88, 0xf7, 0xf5, 0x77, 0x71, 0x8d, 0x0f, 0xf5, 0x0b, 0x0e, 0x70, 0x75, 0xf6, 0x74, 0xf3, 0x74, + 0x75, 0x11, 0x60, 0x10, 0x12, 0xe7, 0x12, 0x46, 0x91, 0x75, 0xf6, 0x0f, 0xf6, 0xf5, 0x0f, 0x16, + 0x60, 0x14, 0x12, 0xe5, 0x12, 0x44, 0x91, 0xf0, 0xf5, 0xf4, 0x73, 0x14, 0x60, 0x92, 0x62, 0xe9, + 0x58, 0x2c, 0xc7, 0xe0, 0xe4, 0x7d, 0xe2, 0x91, 0x1c, 0xe3, 0x85, 0x47, 0x72, 0x8c, 0x0f, 0x1e, + 0xc9, 0x31, 0x4e, 0x78, 0x2c, 0xc7, 0x70, 0xe1, 0xb1, 0x1c, 0xc3, 0x8d, 0xc7, 0x72, 0x0c, 0x51, + 0x86, 0xe9, 0x99, 0x25, 0x19, 0xa5, 0x49, 0x7a, 0xc9, 0xf9, 0xb9, 0xfa, 0x79, 0xf9, 0x29, 0xa9, + 0x86, 0x06, 0x86, 0xba, 0x99, 0xf9, 0xfa, 0x10, 0x3f, 0xe8, 0x42, 0x03, 0xb0, 0xa0, 0x00, 0x12, + 0x88, 0x25, 0x95, 0x05, 0xa9, 0xc5, 0x49, 0x6c, 0xe0, 0x50, 0x31, 0x06, 0x04, 0x00, 0x00, 0xff, + 0xff, 0x15, 0x7c, 0x5e, 0x70, 0x65, 0x01, 0x00, 0x00, +} + +func (m *TxAuthModeExtension) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TxAuthModeExtension) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *TxAuthModeExtension) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.TxAuthMode != 0 { + i = encodeVarintTxAuth(dAtA, i, uint64(m.TxAuthMode)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func encodeVarintTxAuth(dAtA []byte, offset int, v uint64) int { + offset -= sovTxAuth(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *TxAuthModeExtension) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.TxAuthMode != 0 { + n += 1 + sovTxAuth(uint64(m.TxAuthMode)) + } + return n +} + +func sovTxAuth(x uint64) (n int) { + return (math_bits.Len64(x|1) + 6) / 7 +} +func sozTxAuth(x uint64) (n int) { + return sovTxAuth(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *TxAuthModeExtension) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowTxAuth + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TxAuthModeExtension: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TxAuthModeExtension: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TxAuthMode", wireType) + } + m.TxAuthMode = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowTxAuth + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TxAuthMode |= TxAuthMode(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skipTxAuth(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthTxAuth + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func skipTxAuth(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowTxAuth + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowTxAuth + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowTxAuth + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLengthTxAuth + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroupTxAuth + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLengthTxAuth + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLengthTxAuth = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflowTxAuth = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroupTxAuth = fmt.Errorf("proto: unexpected end of group") +) diff --git a/app/app.go b/app/app.go index b63ee207..6c9b607c 100644 --- a/app/app.go +++ b/app/app.go @@ -9,6 +9,7 @@ import ( "cosmossdk.io/log" storetypes "cosmossdk.io/store/types" circuitkeeper "cosmossdk.io/x/circuit/keeper" + feegrantkeeper "cosmossdk.io/x/feegrant/keeper" upgradekeeper "cosmossdk.io/x/upgrade/keeper" abci "github.com/cometbft/cometbft/abci/types" @@ -45,6 +46,8 @@ import ( ibctransferkeeper "github.com/cosmos/ibc-go/v10/modules/apps/transfer/keeper" ibckeeper "github.com/cosmos/ibc-go/v10/modules/core/keeper" + authante "github.com/cosmos/cosmos-sdk/x/auth/ante" + appante "github.com/node101-io/pulsar-chain/app/ante" "github.com/node101-io/pulsar-chain/docs" keyregistrymodulekeeper "github.com/node101-io/pulsar-chain/x/keyregistry/keeper" pulsarmodulekeeper "github.com/node101-io/pulsar-chain/x/pulsar/keeper" @@ -91,6 +94,7 @@ type App struct { AuthzKeeper authzkeeper.Keeper ConsensusParamsKeeper consensuskeeper.Keeper CircuitBreakerKeeper circuitkeeper.Keeper + FeeGrantKeeper feegrantkeeper.Keeper ParamsKeeper paramskeeper.Keeper // ibc keepers @@ -181,6 +185,7 @@ func New( &app.AuthzKeeper, &app.ConsensusParamsKeeper, &app.CircuitBreakerKeeper, + &app.FeeGrantKeeper, &app.ParamsKeeper, &app.PulsarKeeper, &app.KeyregistryKeeper, @@ -188,9 +193,31 @@ func New( panic(err) } + appante.RegisterInterfaces(app.interfaceRegistry) + // add to default baseapp options // enable optimistic execution - baseAppOptions = append(baseAppOptions, baseapp.SetOptimisticExecution()) + baseAppOptions = append( + baseAppOptions, + baseapp.SetOptimisticExecution(), + func(bApp *baseapp.BaseApp) { + anteHandler, err := appante.NewAnteHandler(appante.HandlerOptions{ + AccountKeeper: app.AuthKeeper, + BankKeeper: app.BankKeeper, + FeegrantKeeper: app.FeeGrantKeeper, + SignModeHandler: app.txConfig.SignModeHandler(), + SigGasConsumer: authante.DefaultSigVerificationGasConsumer, + MinaAddressResolver: app.KeyregistryKeeper, + MinaNetworkID: appante.DefaultMinaNetworkID, + Logger: logger, + }) + if err != nil { + panic(err) + } + + bApp.SetAnteHandler(anteHandler) + }, + ) // build app app.App = appBuilder.Build(db, traceStore, baseAppOptions...) diff --git a/app/app_config.go b/app/app_config.go index db90a02e..f986e86d 100644 --- a/app/app_config.go +++ b/app/app_config.go @@ -213,7 +213,7 @@ var ( }, { Name: "tx", - Config: appconfig.WrapAny(&txconfigv1.Config{}), + Config: appconfig.WrapAny(&txconfigv1.Config{SkipAnteHandler: true}), }, { Name: genutiltypes.ModuleName, diff --git a/cmd/pulsard/cmd/root.go b/cmd/pulsard/cmd/root.go index e692cfeb..893f4800 100644 --- a/cmd/pulsard/cmd/root.go +++ b/cmd/pulsard/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/cobra" "github.com/node101-io/pulsar-chain/app" + appante "github.com/node101-io/pulsar-chain/app/ante" ) // NewRootCmd creates a new root command for pulsard. It is called once in the main function. @@ -99,6 +100,8 @@ func ProvideClientContext( txConfigOpts tx.ConfigOptions, legacyAmino *codec.LegacyAmino, ) client.Context { + appante.RegisterInterfaces(interfaceRegistry) + clientCtx := client.Context{}. WithCodec(appCodec). WithInterfaceRegistry(interfaceRegistry). diff --git a/docs/static/openapi.json b/docs/static/openapi.json index 8340f427..3a830c98 100644 --- a/docs/static/openapi.json +++ b/docs/static/openapi.json @@ -1 +1 @@ -{"id":"github.com/node101-io/pulsar-chain","consumes":["application/json"],"produces":["application/json"],"swagger":"2.0","info":{"description":"Chain github.com/node101-io/pulsar-chain REST API","title":"HTTP API Console","contact":{"name":"github.com/node101-io/pulsar-chain"},"version":"version not set"},"paths":{"/node101-io/pulsar-chain/keyregistry/v1/get_cosmos_pub_key/{mina_pub_key}":{"get":{"tags":["Query"],"summary":"GetCosmosPubKey Queries a list of GetCosmosPubKey items.","operationId":"GithubComnode101IopulsarChainQuery_GetCosmosPubKey","parameters":[{"type":"string","format":"byte","name":"mina_pub_key","in":"path","required":true}],"responses":{"200":{"description":"A successful response.","schema":{"$ref":"#/definitions/pulsarchain.keyregistry.v1.QueryGetCosmosPubKeyResponse"}},"default":{"description":"An unexpected error response.","schema":{"$ref":"#/definitions/google.rpc.Status"}}}}},"/node101-io/pulsar-chain/keyregistry/v1/get_mina_pub_key/{cosmos_pub_key}":{"get":{"tags":["Query"],"summary":"GetMinaPubKey Queries a list of GetMinaPubKey items.","operationId":"GithubComnode101IopulsarChainQuery_GetMinaPubKey","parameters":[{"type":"string","format":"byte","name":"cosmos_pub_key","in":"path","required":true}],"responses":{"200":{"description":"A successful response.","schema":{"$ref":"#/definitions/pulsarchain.keyregistry.v1.QueryGetMinaPubKeyResponse"}},"default":{"description":"An unexpected error response.","schema":{"$ref":"#/definitions/google.rpc.Status"}}}}},"/node101-io/pulsar-chain/keyregistry/v1/params":{"get":{"tags":["Query"],"summary":"Parameters queries the parameters of the module.","operationId":"GithubComnode101IopulsarChainQuery_ParamsMixin7","responses":{"200":{"description":"A successful response.","schema":{"$ref":"#/definitions/pulsarchain.keyregistry.v1.QueryParamsResponse"}},"default":{"description":"An unexpected error response.","schema":{"$ref":"#/definitions/google.rpc.Status"}}}}},"/pulsar/pulsar/v1/params":{"get":{"tags":["Query"],"summary":"Parameters queries the parameters of the module.","operationId":"GithubComnode101IopulsarChainQuery_Params","responses":{"200":{"description":"A successful response.","schema":{"$ref":"#/definitions/pulsar.pulsar.v1.QueryParamsResponse"}},"default":{"description":"An unexpected error response.","schema":{"$ref":"#/definitions/google.rpc.Status"}}}}}},"definitions":{"google.protobuf.Any":{"type":"object","properties":{"@type":{"type":"string"}},"additionalProperties":{}},"google.rpc.Status":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"details":{"type":"array","items":{"type":"object","$ref":"#/definitions/google.protobuf.Any"}},"message":{"type":"string"}}},"pulsar.pulsar.v1.Params":{"description":"Params defines the parameters for the module.","type":"object"},"pulsar.pulsar.v1.QueryParamsResponse":{"description":"QueryParamsResponse is response type for the Query/Params RPC method.","type":"object","properties":{"params":{"description":"params holds all the parameters of this module.","$ref":"#/definitions/pulsar.pulsar.v1.Params"}}},"pulsarchain.keyregistry.v1.Params":{"description":"Params defines the parameters for the module.","type":"object"},"pulsarchain.keyregistry.v1.QueryGetCosmosPubKeyResponse":{"description":"QueryGetCosmosPubKeyResponse defines the QueryGetCosmosPubKeyResponse message.","type":"object","properties":{"cosmos_pub_key":{"type":"string","format":"byte"}}},"pulsarchain.keyregistry.v1.QueryGetMinaPubKeyResponse":{"description":"QueryGetMinaPubKeyResponse defines the QueryGetMinaPubKeyResponse message.","type":"object","properties":{"mina_pub_key":{"type":"string","format":"byte"}}},"pulsarchain.keyregistry.v1.QueryParamsResponse":{"description":"QueryParamsResponse is response type for the Query/Params RPC method.","type":"object","properties":{"params":{"description":"params holds all the parameters of this module.","$ref":"#/definitions/pulsarchain.keyregistry.v1.Params"}}}},"tags":[{"name":"Query"},{"name":"Msg"}]} \ No newline at end of file +{"id":"github.com/node101-io/pulsar-chain","consumes":["application/json"],"produces":["application/json"],"swagger":"2.0","info":{"description":"Chain github.com/node101-io/pulsar-chain REST API","title":"HTTP API Console","contact":{"name":"github.com/node101-io/pulsar-chain"},"version":"version not set"},"paths":{"/node101-io/pulsar-chain/keyregistry/v1/get_cosmos_pub_key/{mina_pub_key}":{"get":{"tags":["Query"],"summary":"GetCosmosPubKey Queries a list of GetCosmosPubKey items.","operationId":"GithubComnode101IopulsarChainQuery_GetCosmosPubKey","parameters":[{"type":"string","format":"byte","name":"mina_pub_key","in":"path","required":true}],"responses":{"200":{"description":"A successful response.","schema":{"$ref":"#/definitions/pulsarchain.keyregistry.v1.QueryGetCosmosPubKeyResponse"}},"default":{"description":"An unexpected error response.","schema":{"$ref":"#/definitions/google.rpc.Status"}}}}},"/node101-io/pulsar-chain/keyregistry/v1/get_mina_pub_key/{cosmos_pub_key}":{"get":{"tags":["Query"],"summary":"GetMinaPubKey Queries a list of GetMinaPubKey items.","operationId":"GithubComnode101IopulsarChainQuery_GetMinaPubKey","parameters":[{"type":"string","format":"byte","name":"cosmos_pub_key","in":"path","required":true}],"responses":{"200":{"description":"A successful response.","schema":{"$ref":"#/definitions/pulsarchain.keyregistry.v1.QueryGetMinaPubKeyResponse"}},"default":{"description":"An unexpected error response.","schema":{"$ref":"#/definitions/google.rpc.Status"}}}}},"/node101-io/pulsar-chain/keyregistry/v1/params":{"get":{"tags":["Query"],"summary":"Parameters queries the parameters of the module.","operationId":"GithubComnode101IopulsarChainQuery_ParamsMixin8","responses":{"200":{"description":"A successful response.","schema":{"$ref":"#/definitions/pulsarchain.keyregistry.v1.QueryParamsResponse"}},"default":{"description":"An unexpected error response.","schema":{"$ref":"#/definitions/google.rpc.Status"}}}}},"/pulsar/pulsar/v1/params":{"get":{"tags":["Query"],"summary":"Parameters queries the parameters of the module.","operationId":"GithubComnode101IopulsarChainQuery_Params","responses":{"200":{"description":"A successful response.","schema":{"$ref":"#/definitions/pulsar.pulsar.v1.QueryParamsResponse"}},"default":{"description":"An unexpected error response.","schema":{"$ref":"#/definitions/google.rpc.Status"}}}}}},"definitions":{"google.protobuf.Any":{"type":"object","properties":{"@type":{"type":"string"}},"additionalProperties":{}},"google.rpc.Status":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"details":{"type":"array","items":{"type":"object","$ref":"#/definitions/google.protobuf.Any"}},"message":{"type":"string"}}},"pulsar.pulsar.v1.Params":{"description":"Params defines the parameters for the module.","type":"object"},"pulsar.pulsar.v1.QueryParamsResponse":{"description":"QueryParamsResponse is response type for the Query/Params RPC method.","type":"object","properties":{"params":{"description":"params holds all the parameters of this module.","$ref":"#/definitions/pulsar.pulsar.v1.Params"}}},"pulsarchain.keyregistry.v1.Params":{"description":"Params defines the parameters for the module.","type":"object"},"pulsarchain.keyregistry.v1.QueryGetCosmosPubKeyResponse":{"description":"QueryGetCosmosPubKeyResponse defines the QueryGetCosmosPubKeyResponse message.","type":"object","properties":{"cosmos_pub_key":{"type":"string","format":"byte"}}},"pulsarchain.keyregistry.v1.QueryGetMinaPubKeyResponse":{"description":"QueryGetMinaPubKeyResponse defines the QueryGetMinaPubKeyResponse message.","type":"object","properties":{"mina_pub_key":{"type":"string","format":"byte"}}},"pulsarchain.keyregistry.v1.QueryParamsResponse":{"description":"QueryParamsResponse is response type for the Query/Params RPC method.","type":"object","properties":{"params":{"description":"params holds all the parameters of this module.","$ref":"#/definitions/pulsarchain.keyregistry.v1.Params"}}}},"tags":[{"name":"Query"},{"name":"Msg"}]} \ No newline at end of file diff --git a/go.mod b/go.mod index 780ad485..683381c7 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( cosmossdk.io/x/evidence v0.1.1 cosmossdk.io/x/feegrant v0.1.1 cosmossdk.io/x/nft v0.1.0 + cosmossdk.io/x/tx v0.14.0 cosmossdk.io/x/upgrade v0.2.0 github.com/cometbft/cometbft v0.38.21 github.com/cosmos/cosmos-db v1.1.3 @@ -76,7 +77,6 @@ require ( connectrpc.com/connect v1.19.1 // indirect connectrpc.com/otelconnect v0.9.0 // indirect cosmossdk.io/schema v1.1.0 // indirect - cosmossdk.io/x/tx v0.14.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/4meepo/tagalign v1.4.2 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect diff --git a/proto/pulsarchain/ante/v1/tx_auth.proto b/proto/pulsarchain/ante/v1/tx_auth.proto new file mode 100644 index 00000000..3de08394 --- /dev/null +++ b/proto/pulsarchain/ante/v1/tx_auth.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package pulsarchain.ante.v1; + +import "gogoproto/gogo.proto"; + +option go_package = "github.com/node101-io/pulsar-chain/app/ante/types"; + +// TxAuthMode determines which signature verification path a tx should use. +enum TxAuthMode { + option (gogoproto.goproto_enum_prefix) = false; + + TX_AUTH_MODE_UNSPECIFIED = 0; + TX_AUTH_MODE_COSMOS = 1; + TX_AUTH_MODE_MINA = 2; +} + +// TxAuthModeExtension marks the authentication mode of a transaction. +message TxAuthModeExtension { + option (gogoproto.goproto_getters) = false; + + TxAuthMode tx_auth_mode = 1; +} From cc863019bc0c6790fc8670e89ae3258aa35308a0 Mon Sep 17 00:00:00 2001 From: korayakpinar Date: Sat, 14 Mar 2026 16:35:05 +0300 Subject: [PATCH 2/2] test: added new tests and refactored the older tests for ante package --- app/ante/handler_test.go | 14 + app/ante/mina_verifier_test.go | 511 +++++++++++++++++++++- app/ante/routed_decorators_test.go | 124 ------ app/ante/routed_setpubkey_test.go | 56 +++ app/ante/routed_siggascost_test.go | 228 ++++++++++ app/ante/routed_sigverify_test.go | 83 ++++ app/ante/routed_validate_sigcount_test.go | 243 ++++++++++ app/ante/test_helpers_test.go | 31 ++ app/ante/tx_mode_test.go | 15 + 9 files changed, 1176 insertions(+), 129 deletions(-) delete mode 100644 app/ante/routed_decorators_test.go create mode 100644 app/ante/routed_setpubkey_test.go create mode 100644 app/ante/routed_siggascost_test.go create mode 100644 app/ante/routed_sigverify_test.go create mode 100644 app/ante/routed_validate_sigcount_test.go diff --git a/app/ante/handler_test.go b/app/ante/handler_test.go index 60caef31..079dfc72 100644 --- a/app/ante/handler_test.go +++ b/app/ante/handler_test.go @@ -73,6 +73,8 @@ func (stubMinaAddressResolver) GetCosmosToMina(context.Context, []byte) ([]byte, return nil, nil } +// A fully populated HandlerOptions should construct a usable ante chain. +// This is the baseline success case that all of the stricter validation tests compare against. func TestNewAnteHandler(t *testing.T) { t.Parallel() @@ -89,6 +91,8 @@ func TestNewAnteHandler(t *testing.T) { require.NotNil(t, anteHandler) } +// AccountKeeper is required because the auth ante stack depends on account state almost everywhere. +// Failing at construction time is safer than letting a partially wired handler reach runtime. func TestNewAnteHandlerRequiresAccountKeeper(t *testing.T) { t.Parallel() @@ -104,6 +108,8 @@ func TestNewAnteHandlerRequiresAccountKeeper(t *testing.T) { require.Nil(t, anteHandler) } +// BankKeeper is mandatory for fee deduction in the default auth decorators. +// This test ensures the constructor rejects missing fee-transfer dependencies immediately. func TestNewAnteHandlerRequiresBankKeeper(t *testing.T) { t.Parallel() @@ -119,6 +125,8 @@ func TestNewAnteHandlerRequiresBankKeeper(t *testing.T) { require.Nil(t, anteHandler) } +// SignModeHandler is needed by both the Cosmos and Mina signature verification paths. +// Missing it would make sign-byte generation impossible later in the ante chain. func TestNewAnteHandlerRequiresSignModeHandler(t *testing.T) { t.Parallel() @@ -134,6 +142,8 @@ func TestNewAnteHandlerRequiresSignModeHandler(t *testing.T) { require.Nil(t, anteHandler) } +// MinaAddressResolver is part of the custom verifier contract for Mina-authenticated txs. +// The constructor should refuse to build an ante handler that can never resolve Mina signers. func TestNewAnteHandlerRequiresMinaAddressResolver(t *testing.T) { t.Parallel() @@ -149,6 +159,8 @@ func TestNewAnteHandlerRequiresMinaAddressResolver(t *testing.T) { require.Nil(t, anteHandler) } +// Mina network selection affects how Mina signatures are verified on-chain. +// This test guards against silently constructing a verifier with an empty network ID. func TestNewAnteHandlerRequiresMinaNetworkID(t *testing.T) { t.Parallel() @@ -164,6 +176,8 @@ func TestNewAnteHandlerRequiresMinaNetworkID(t *testing.T) { require.Nil(t, anteHandler) } +// The custom verifier emits debug information when verification is skipped. +// Requiring a logger up front avoids nil logger surprises during ante execution. func TestNewAnteHandlerRequiresLogger(t *testing.T) { t.Parallel() diff --git a/app/ante/mina_verifier_test.go b/app/ante/mina_verifier_test.go index 5f7246ec..ef7864ab 100644 --- a/app/ante/mina_verifier_test.go +++ b/app/ante/mina_verifier_test.go @@ -7,12 +7,25 @@ import ( "time" "cosmossdk.io/core/address" + "cosmossdk.io/x/tx/signing" "cosmossdk.io/log" + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" txsigning "cosmossdk.io/x/tx/signing" cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" + "github.com/cosmos/cosmos-sdk/std" + "github.com/cosmos/cosmos-sdk/x/auth" + authcodec "github.com/cosmos/cosmos-sdk/x/auth/codec" + authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" + authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + gogoproto "github.com/cosmos/gogoproto/proto" "github.com/node101-io/mina-signer-go/keys" "github.com/stretchr/testify/require" protov2 "google.golang.org/protobuf/proto" @@ -65,20 +78,34 @@ func (r *verifierResolver) GetCosmosToMina(context.Context, []byte) ([]byte, err } type stubAuthTx struct { - signers [][]byte - sigs []signingtypes.SignatureV2 - unordered bool + signers [][]byte + signersErr error + sigs []signingtypes.SignatureV2 + sigsErr error + unordered bool } func (tx stubAuthTx) GetMsgs() []sdk.Msg { return nil } func (tx stubAuthTx) GetMsgsV2() ([]protov2.Message, error) { return nil, nil } -func (tx stubAuthTx) GetSigners() ([][]byte, error) { return tx.signers, nil } +func (tx stubAuthTx) GetSigners() ([][]byte, error) { + if tx.signersErr != nil { + return nil, tx.signersErr + } + + return tx.signers, nil +} func (tx stubAuthTx) GetPubKeys() ([]cryptotypes.PubKey, error) { return nil, nil } -func (tx stubAuthTx) GetSignaturesV2() ([]signingtypes.SignatureV2, error) { return tx.sigs, nil } +func (tx stubAuthTx) GetSignaturesV2() ([]signingtypes.SignatureV2, error) { + if tx.sigsErr != nil { + return nil, tx.sigsErr + } + + return tx.sigs, nil +} func (tx stubAuthTx) GetMemo() string { return "" } @@ -98,6 +125,8 @@ func (tx stubAuthTx) FeePayer() []byte { return nil } func (tx stubAuthTx) FeeGranter() []byte { return nil } +// newVerifierForTest keeps the early-branch tests lightweight by wiring a minimal verifier. +// It is paired with an empty HandlerMap because those tests never build real sign bytes. func newVerifierForTest( account sdk.AccountI, resolver *verifierResolver, @@ -111,6 +140,170 @@ func newVerifierForTest( ) } +type verifierEncodingConfig struct { + TxConfig client.TxConfig +} + +// newVerifierEncodingConfig builds a real tx config with address codecs enabled. +// The end-to-end Mina signature tests need this so signers and sign bytes match production encoding. +func newVerifierEncodingConfig(t *testing.T) verifierEncodingConfig { + t.Helper() + + interfaceRegistry, err := codectypes.NewInterfaceRegistryWithOptions(codectypes.InterfaceRegistryOptions{ + ProtoFiles: gogoproto.HybridResolver, + SigningOptions: signing.Options{ + AddressCodec: authcodec.NewBech32Codec(sdk.GetConfig().GetBech32AccountAddrPrefix()), + ValidatorAddressCodec: authcodec.NewBech32Codec(sdk.GetConfig().GetBech32ValidatorAddrPrefix()), + }, + }) + require.NoError(t, err) + std.RegisterInterfaces(interfaceRegistry) + auth.AppModuleBasic{}.RegisterInterfaces(interfaceRegistry) + banktypes.RegisterInterfaces(interfaceRegistry) + + protoCodec := codec.NewProtoCodec(interfaceRegistry) + txConfig := authtx.NewTxConfig(protoCodec, authtx.DefaultSignModes) + + return verifierEncodingConfig{TxConfig: txConfig} +} + +// newVerifierWithRealSignModeHandlerForTest wires the verifier to a real sign-mode handler. +// The lower verifier branches call GetSignBytesAdapter, so stub handlers are not sufficient there. +func newVerifierWithRealSignModeHandlerForTest( + t *testing.T, + account sdk.AccountI, + resolver *verifierResolver, +) (MinaVerifier, verifierEncodingConfig) { + t.Helper() + + encoding := newVerifierEncodingConfig(t) + + return NewMinaVerifier( + resolver, + verifierAccountKeeper{account: account}, + encoding.TxConfig.SignModeHandler(), + DefaultMinaNetworkID, + log.NewNopLogger(), + ), encoding +} + +// newVerifierAccount creates a signer account with real secp256k1 key material and sequence metadata. +// The verifier reads address, account number and sequence from this account during validation. +func newVerifierAccount(t *testing.T, accountNumber uint64, sequence uint64) (*secp256k1.PrivKey, sdk.AccountI) { + t.Helper() + + cosmosPrivKey := secp256k1.GenPrivKey() + address := sdk.AccAddress(cosmosPrivKey.PubKey().Address()) + + return cosmosPrivKey, authtypes.NewBaseAccount(address, cosmosPrivKey.PubKey(), accountNumber, sequence) +} + +// newMinaPrivateKeyForTest derives deterministic Mina keys from a tiny seed marker. +// Deterministic keys keep the signature tests reproducible while staying easy to read. +func newMinaPrivateKeyForTest(t *testing.T, marker byte) *keys.PrivateKey { + t.Helper() + + var seed [32]byte + seed[0] = marker + + privateKey := keys.NewPrivateKeyFromBytes(seed) + require.NotNil(t, privateKey) + + return &privateKey +} + +// buildVerifierTestTx creates the smallest real SDK tx shape the verifier can inspect and re-sign. +// Using a real TxBuilder keeps signer extraction and sign-byte generation aligned with production. +func buildVerifierTestTx( + t *testing.T, + txConfig client.TxConfig, + from sdk.AccAddress, + pubKey cryptotypes.PubKey, + sequence uint64, + signMode signingtypes.SignMode, + signature []byte, +) sdk.Tx { + t.Helper() + + builder := txConfig.NewTxBuilder() + to := sdk.AccAddress([]byte("verifier-recipient-1")) + + err := builder.SetMsgs(banktypes.NewMsgSend(from, to, sdk.NewCoins(sdk.NewInt64Coin("pmina", 1)))) + require.NoError(t, err) + + builder.SetGasLimit(200000) + builder.SetFeeAmount(sdk.NewCoins(sdk.NewInt64Coin("pmina", 1))) + builder.SetMemo("mina-verifier-test") + + err = builder.SetSignatures(signingtypes.SignatureV2{ + PubKey: pubKey, + Data: &signingtypes.SingleSignatureData{ + SignMode: signMode, + Signature: signature, + }, + Sequence: sequence, + }) + require.NoError(t, err) + + return builder.GetTx() +} + +// buildVerifierSignBytes reproduces the exact sign-byte payload verifySingleSignature expects. +// Tests use it to create both valid and intentionally invalid Mina signatures against a real tx. +func buildVerifierSignBytes( + t *testing.T, + ctx sdk.Context, + txConfig client.TxConfig, + tx sdk.Tx, + account sdk.AccountI, + sequence uint64, + signMode signingtypes.SignMode, +) []byte { + t.Helper() + + var accountNumber uint64 + if ctx.BlockHeight() > 0 { + accountNumber = account.GetAccountNumber() + } + + signBytes, err := authsigning.GetSignBytesAdapter( + ctx, + txConfig.SignModeHandler(), + signMode, + authsigning.SignerData{ + Address: account.GetAddress().String(), + ChainID: ctx.ChainID(), + AccountNumber: accountNumber, + Sequence: sequence, + }, + tx, + ) + require.NoError(t, err) + + return signBytes +} + +// signMinaBytes turns raw sign bytes into the wire-format payload consumed by the verifier. +// This keeps the success and crypto-failure tests working with real Mina signatures instead of stubs. +func signMinaBytes( + t *testing.T, + privateKey *keys.PrivateKey, + message []byte, + networkID string, +) []byte { + t.Helper() + + signature, err := privateKey.SignMessage(string(message), networkID) + require.NoError(t, err) + + signatureBytes, err := signature.MarshalBytes() + require.NoError(t, err) + + return signatureBytes +} + +// Unordered txs are intentionally unsupported on the Mina path. +// This guards an early policy branch before any signer or signature processing happens. func TestMinaVerifierRejectsUnorderedTransactions(t *testing.T) { t.Parallel() @@ -125,6 +318,69 @@ func TestMinaVerifierRejectsUnorderedTransactions(t *testing.T) { require.ErrorContains(t, err, "unordered Mina transactions are not supported") } +// VerifySignatures only supports authsigning.Tx implementations. +// A plain sdk.Tx should fail before any signer inspection starts. +func TestMinaVerifierRejectsInvalidTransactionType(t *testing.T) { + t.Parallel() + _, account := newVerifierAccount(t, 1, 7) + verifier := newVerifierForTest(account, &verifierResolver{}) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubBasicTx{}, false) + + require.ErrorIs(t, err, sdkerrors.ErrTxDecode) + require.ErrorContains(t, err, "invalid transaction type") +} + +// Signature loading errors come from the tx and should bubble up unchanged. +// The verifier should stop before touching account lookup or registry resolution. +func TestMinaVerifierPropagatesGetSignaturesError(t *testing.T) { + t.Parallel() + _, account := newVerifierAccount(t, 1, 7) + verifier := newVerifierForTest(account, &verifierResolver{}) + expectedErr := errors.New("signatures unavailable") + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + sigsErr: expectedErr, + }, false) + + require.ErrorIs(t, err, expectedErr) +} + +// Signer loading errors are also tx-owned and should not be wrapped away. +// This covers the second early-return branch before signer/signature count validation. +func TestMinaVerifierPropagatesGetSignersError(t *testing.T) { + t.Parallel() + _, account := newVerifierAccount(t, 1, 7) + verifier := newVerifierForTest(account, &verifierResolver{}) + expectedErr := errors.New("signers unavailable") + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + sigs: []signingtypes.SignatureV2{{}}, + signersErr: expectedErr, + }, false) + + require.ErrorIs(t, err, expectedErr) +} + +// Each signer must have exactly one matching SignatureV2 entry. +// Mismatched lengths should fail before any account lookup or crypto work begins. +func TestMinaVerifierRejectsSignerSignatureCountMismatch(t *testing.T) { + t.Parallel() + address := sdk.AccAddress([]byte("verifier-address-mismatch")) + _, account := newVerifierAccount(t, 1, 7) + verifier := newVerifierForTest(account, &verifierResolver{}) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + signers: [][]byte{address}, + sigs: nil, + }, false) + + require.ErrorIs(t, err, sdkerrors.ErrUnauthorized) + require.ErrorContains(t, err, "invalid number of signer") +} + +// Sequence mismatches must fail before any expensive Mina-specific verification happens. +// This mirrors the standard auth invariant while still exercising the Mina verifier path. func TestMinaVerifierRejectsWrongSequence(t *testing.T) { t.Parallel() @@ -148,6 +404,8 @@ func TestMinaVerifierRejectsWrongSequence(t *testing.T) { require.ErrorContains(t, err, "account sequence mismatch") } +// Mina-auth only supports single signatures in this chain. +// Multisig payloads should be rejected before registry lookup or sign-byte generation. func TestMinaVerifierRejectsNonSingleSignatureData(t *testing.T) { t.Parallel() @@ -168,6 +426,8 @@ func TestMinaVerifierRejectsNonSingleSignatureData(t *testing.T) { require.ErrorContains(t, err, "mina transactions require single signature data") } +// Missing registry entries should be reported as authorization failures. +// This is the bridge between Cosmos signer identity and Mina key resolution. func TestMinaVerifierRejectsMissingRegistryEntry(t *testing.T) { t.Parallel() @@ -193,6 +453,35 @@ func TestMinaVerifierRejectsMissingRegistryEntry(t *testing.T) { require.Equal(t, 1, resolver.calls) } +// Resolver output must be a valid Mina address string because verifier parses it. +// Invalid address bytes should fail before signature decoding or sign-byte generation. +func TestMinaVerifierRejectsInvalidRegisteredMinaAddress(t *testing.T) { + t.Parallel() + address := sdk.AccAddress([]byte("verifier-address-invalid-mina")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + resolver := &verifierResolver{minaAddress: []byte("not-a-mina-address")} + verifier := newVerifierForTest(account, resolver) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true), stubAuthTx{ + signers: [][]byte{address}, + sigs: []signingtypes.SignatureV2{ + { + Sequence: 7, + Data: &signingtypes.SingleSignatureData{ + SignMode: signingtypes.SignMode_SIGN_MODE_DIRECT, + Signature: []byte{1}, + }, + }, + }, + }, false) + + require.ErrorIs(t, err, sdkerrors.ErrInvalidPubKey) + require.ErrorContains(t, err, "failed to parse Mina public key") + require.Equal(t, 1, resolver.calls) +} + +// Signature payloads must decode as real Mina signatures before any sign-byte comparison can happen. +// This keeps malformed wire data distinct from valid-but-unauthorized signatures. func TestMinaVerifierRejectsInvalidSignatureEncoding(t *testing.T) { t.Parallel() @@ -224,6 +513,8 @@ func TestMinaVerifierRejectsInvalidSignatureEncoding(t *testing.T) { require.Equal(t, 1, resolver.calls) } +// Simulation mode skips expensive Mina crypto while leaving the outer verifier flow intact. +// Resolver should not be consulted because no actual signature check is meant to happen. func TestMinaVerifierSkipsCryptoVerificationDuringSimulation(t *testing.T) { t.Parallel() @@ -248,3 +539,213 @@ func TestMinaVerifierSkipsCryptoVerificationDuringSimulation(t *testing.T) { require.NoError(t, err) require.Zero(t, resolver.calls) } + +// ReCheckTx skips expensive signature verification but still enforces cheap invariants. +// Resolver should not be called when the recheck flag is set. +func TestMinaVerifierSkipsCryptoVerificationDuringRecheck(t *testing.T) { + t.Parallel() + address := sdk.AccAddress([]byte("verifier-address-recheck")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + resolver := &verifierResolver{err: errors.New("should not be called")} + verifier := newVerifierForTest(account, resolver) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(true).WithIsReCheckTx(true), stubAuthTx{ + signers: [][]byte{address}, + sigs: []signingtypes.SignatureV2{ + { + Sequence: 7, + Data: &signingtypes.SingleSignatureData{ + SignMode: signingtypes.SignMode_SIGN_MODE_DIRECT, + Signature: []byte{1}, + }, + }, + }, + }, false) + + require.NoError(t, err) + require.Zero(t, resolver.calls) +} + +// Contexts that disable sigverify should bypass Mina crypto checks entirely. +// This branch is distinct from simulation and recheck, so it needs its own coverage. +func TestMinaVerifierSkipsCryptoVerificationWhenSigverifyDisabled(t *testing.T) { + t.Parallel() + address := sdk.AccAddress([]byte("verifier-address-nosigverify")) + account := authtypes.NewBaseAccount(address, nil, 1, 7) + resolver := &verifierResolver{err: errors.New("should not be called")} + verifier := newVerifierForTest(account, resolver) + + err := verifier.VerifySignatures(newTestSDKContext(t).WithIsSigverifyTx(false), stubAuthTx{ + signers: [][]byte{address}, + sigs: []signingtypes.SignatureV2{ + { + Sequence: 7, + Data: &signingtypes.SingleSignatureData{ + SignMode: signingtypes.SignMode_SIGN_MODE_DIRECT, + Signature: []byte{1}, + }, + }, + }, + }, false) + + require.NoError(t, err) + require.Zero(t, resolver.calls) +} + +// A decodable Mina signature is not enough if the sign mode has no registered handler. +// This covers the sign-bytes generation failure branch inside verifySingleSignature. +func TestMinaVerifierRejectsUnsupportedSignMode(t *testing.T) { + t.Parallel() + ctx := newTestSDKContext(t).WithIsSigverifyTx(true) + cosmosPrivKey, account := newVerifierAccount(t, 5, 9) + minaPrivKey := newMinaPrivateKeyForTest(t, 21) + minaAddress, err := minaPrivKey.ToPublicKey().ToAddress() + require.NoError(t, err) + resolver := &verifierResolver{minaAddress: []byte(minaAddress)} + verifier, encoding := newVerifierWithRealSignModeHandlerForTest(t, account, resolver) + + // The signature must decode successfully so the verifier reaches sign-bytes generation. + signatureBytes := signMinaBytes(t, minaPrivKey, []byte("unsupported-sign-mode"), DefaultMinaNetworkID) + tx := buildVerifierTestTx( + t, + encoding.TxConfig, + account.GetAddress(), + cosmosPrivKey.PubKey(), + account.GetSequence(), + signingtypes.SignMode(999), + signatureBytes, + ) + + err = verifier.VerifySignatures(ctx, tx, false) + + require.ErrorIs(t, err, sdkerrors.ErrInvalidType) + require.ErrorContains(t, err, "failed to generate sign bytes") + require.Equal(t, 1, resolver.calls) +} + +// The verifier must reject signatures that decode correctly but were made over the wrong message. +// This distinguishes actual cryptographic failure from malformed signature bytes. +func TestMinaVerifierRejectsCryptographicallyInvalidSignature(t *testing.T) { + t.Parallel() + ctx := newTestSDKContext(t).WithIsSigverifyTx(true) + cosmosPrivKey, account := newVerifierAccount(t, 6, 10) + minaPrivKey := newMinaPrivateKeyForTest(t, 22) + minaAddress, err := minaPrivKey.ToPublicKey().ToAddress() + require.NoError(t, err) + resolver := &verifierResolver{minaAddress: []byte(minaAddress)} + verifier, encoding := newVerifierWithRealSignModeHandlerForTest(t, account, resolver) + + signMode := signingtypes.SignMode(encoding.TxConfig.SignModeHandler().DefaultMode()) + // We first build the real tx shape so the invalid signature targets the exact bytes verifier expects. + tx := buildVerifierTestTx( + t, + encoding.TxConfig, + account.GetAddress(), + cosmosPrivKey.PubKey(), + account.GetSequence(), + signMode, + nil, + ) + + // The signature is valid for a different message, so decoding succeeds but verification fails. + invalidSignatureBytes := signMinaBytes(t, minaPrivKey, []byte("different-sign-bytes"), DefaultMinaNetworkID) + tx = buildVerifierTestTx( + t, + encoding.TxConfig, + account.GetAddress(), + cosmosPrivKey.PubKey(), + account.GetSequence(), + signMode, + invalidSignatureBytes, + ) + + err = verifier.VerifySignatures(ctx, tx, false) + + require.ErrorIs(t, err, sdkerrors.ErrUnauthorized) + require.ErrorContains(t, err, "Mina signature verification failed") + require.Equal(t, 1, resolver.calls) +} + +// A matching Mina key, real sign bytes and valid signature should pass end to end. +// This is the main success path for the verifier's custom crypto flow. +func TestMinaVerifierAcceptsValidSignature(t *testing.T) { + t.Parallel() + ctx := newTestSDKContext(t).WithIsSigverifyTx(true) + cosmosPrivKey, account := newVerifierAccount(t, 7, 11) + minaPrivKey := newMinaPrivateKeyForTest(t, 23) + minaAddress, err := minaPrivKey.ToPublicKey().ToAddress() + require.NoError(t, err) + resolver := &verifierResolver{minaAddress: []byte(minaAddress)} + verifier, encoding := newVerifierWithRealSignModeHandlerForTest(t, account, resolver) + + signMode := signingtypes.SignMode(encoding.TxConfig.SignModeHandler().DefaultMode()) + // The first tx instance gives us the exact bytes the verifier will later reconstruct. + tx := buildVerifierTestTx( + t, + encoding.TxConfig, + account.GetAddress(), + cosmosPrivKey.PubKey(), + account.GetSequence(), + signMode, + nil, + ) + signBytes := buildVerifierSignBytes(t, ctx, encoding.TxConfig, tx, account, account.GetSequence(), signMode) + signatureBytes := signMinaBytes(t, minaPrivKey, signBytes, DefaultMinaNetworkID) + // Rebuild the tx with the real signature so VerifySignatures sees the same payload it validates. + tx = buildVerifierTestTx( + t, + encoding.TxConfig, + account.GetAddress(), + cosmosPrivKey.PubKey(), + account.GetSequence(), + signMode, + signatureBytes, + ) + + err = verifier.VerifySignatures(ctx, tx, false) + + require.NoError(t, err) + require.Equal(t, 1, resolver.calls) +} + +// At genesis height the verifier intentionally signs with account number zero. +// This test locks down that branch with a valid end-to-end signature. +func TestMinaVerifierUsesZeroAccountNumberAtGenesisHeight(t *testing.T) { + t.Parallel() + ctx := newTestSDKContext(t).WithBlockHeight(0).WithIsSigverifyTx(true) + cosmosPrivKey, account := newVerifierAccount(t, 99, 12) + minaPrivKey := newMinaPrivateKeyForTest(t, 24) + minaAddress, err := minaPrivKey.ToPublicKey().ToAddress() + require.NoError(t, err) + resolver := &verifierResolver{minaAddress: []byte(minaAddress)} + verifier, encoding := newVerifierWithRealSignModeHandlerForTest(t, account, resolver) + + signMode := signingtypes.SignMode(encoding.TxConfig.SignModeHandler().DefaultMode()) + // The sign bytes here intentionally omit the account number because block height is zero. + tx := buildVerifierTestTx( + t, + encoding.TxConfig, + account.GetAddress(), + cosmosPrivKey.PubKey(), + account.GetSequence(), + signMode, + nil, + ) + signBytes := buildVerifierSignBytes(t, ctx, encoding.TxConfig, tx, account, account.GetSequence(), signMode) + signatureBytes := signMinaBytes(t, minaPrivKey, signBytes, DefaultMinaNetworkID) + // Rebuilding the tx with the real signature ensures the verifier sees the exact genesis-path payload. + tx = buildVerifierTestTx( + t, + encoding.TxConfig, + account.GetAddress(), + cosmosPrivKey.PubKey(), + account.GetSequence(), + signMode, + signatureBytes, + ) + + err = verifier.VerifySignatures(ctx, tx, false) + + require.NoError(t, err) + require.Equal(t, 1, resolver.calls) +} diff --git a/app/ante/routed_decorators_test.go b/app/ante/routed_decorators_test.go deleted file mode 100644 index 3e0bfe44..00000000 --- a/app/ante/routed_decorators_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package ante - -import ( - "errors" - "testing" - - sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/stretchr/testify/require" -) - -type recordingDecorator struct { - calls int -} - -func (d *recordingDecorator) AnteHandle( - ctx sdk.Context, - tx sdk.Tx, - simulate bool, - next sdk.AnteHandler, -) (sdk.Context, error) { - d.calls++ - return next(ctx, tx, simulate) -} - -type recordingVerifier struct { - calls int - err error -} - -func (v *recordingVerifier) VerifySignatures(ctx sdk.Context, tx sdk.Tx, simulate bool) error { - v.calls++ - return v.err -} - -func TestRoutedSetPubKeyDecoratorRoutesToCosmosDecorator(t *testing.T) { - t.Parallel() - - ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeCosmos) - delegate := &recordingDecorator{} - nextCalled := false - - _, err := NewRoutedSetPubKeyDecorator(delegate).AnteHandle(ctx, nil, false, func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { - nextCalled = true - return nextCtx, nil - }) - - require.NoError(t, err) - require.Equal(t, 1, delegate.calls) - require.True(t, nextCalled) -} - -func TestRoutedSetPubKeyDecoratorSkipsForMina(t *testing.T) { - t.Parallel() - - ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) - delegate := &recordingDecorator{} - nextCalled := false - - _, err := NewRoutedSetPubKeyDecorator(delegate).AnteHandle(ctx, nil, false, func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { - nextCalled = true - return nextCtx, nil - }) - - require.NoError(t, err) - require.Zero(t, delegate.calls) - require.True(t, nextCalled) -} - -func TestRoutedSigVerificationDecoratorRoutesToCosmosDecorator(t *testing.T) { - t.Parallel() - - ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeCosmos) - delegate := &recordingDecorator{} - verifier := &recordingVerifier{} - nextCalled := false - - _, err := NewRoutedSigVerificationDecorator(delegate, verifier).AnteHandle(ctx, nil, false, func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { - nextCalled = true - return nextCtx, nil - }) - - require.NoError(t, err) - require.Equal(t, 1, delegate.calls) - require.Zero(t, verifier.calls) - require.True(t, nextCalled) -} - -func TestRoutedSigVerificationDecoratorRoutesToMinaVerifier(t *testing.T) { - t.Parallel() - - ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) - delegate := &recordingDecorator{} - verifier := &recordingVerifier{} - nextCalled := false - - _, err := NewRoutedSigVerificationDecorator(delegate, verifier).AnteHandle(ctx, nil, false, func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { - nextCalled = true - return nextCtx, nil - }) - - require.NoError(t, err) - require.Zero(t, delegate.calls) - require.Equal(t, 1, verifier.calls) - require.True(t, nextCalled) -} - -func TestRoutedSigVerificationDecoratorPropagatesVerifierErrors(t *testing.T) { - t.Parallel() - - ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) - expectedErr := errors.New("verify failed") - - _, err := NewRoutedSigVerificationDecorator(&recordingDecorator{}, &recordingVerifier{err: expectedErr}).AnteHandle( - ctx, - nil, - false, - func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { - t.Fatal("next handler should not be called when verifier fails") - return nextCtx, nil - }, - ) - - require.ErrorIs(t, err, expectedErr) -} diff --git a/app/ante/routed_setpubkey_test.go b/app/ante/routed_setpubkey_test.go new file mode 100644 index 00000000..a65ba2a5 --- /dev/null +++ b/app/ante/routed_setpubkey_test.go @@ -0,0 +1,56 @@ +package ante + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" +) + +// Cosmos-auth txs must keep using the wrapped SDK decorator. +// This confirms the custom branch does not interfere outside Mina mode. +func TestRoutedSetPubKeyDecoratorRoutesToCosmosDecorator(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeCosmos) + delegate := &recordingDecorator{} + nextCalled := false + + _, err := NewRoutedSetPubKeyDecorator(delegate).AnteHandle( + ctx, + nil, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.Equal(t, 1, delegate.calls) + require.True(t, nextCalled) +} + +// Mina-auth txs intentionally skip Cosmos pubkey population. +// The decorator should fall through directly to the next handler. +func TestRoutedSetPubKeyDecoratorSkipsForMina(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + delegate := &recordingDecorator{} + nextCalled := false + + _, err := NewRoutedSetPubKeyDecorator(delegate).AnteHandle( + ctx, + nil, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.Zero(t, delegate.calls) + require.True(t, nextCalled) +} diff --git a/app/ante/routed_siggascost_test.go b/app/ante/routed_siggascost_test.go new file mode 100644 index 00000000..082480bc --- /dev/null +++ b/app/ante/routed_siggascost_test.go @@ -0,0 +1,228 @@ +package ante + +import ( + "errors" + "testing" + + storetypes "cosmossdk.io/store/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/stretchr/testify/require" +) + +var errSigGasTestSignaturesUnavailable = errors.New("signatures unavailable") + +// newGasMeteredContext installs a bounded gas meter so tests can assert on gas deltas. +// The default test context uses an infinite meter, which is not useful for accounting checks. +func newGasMeteredContext(t *testing.T) sdk.Context { + t.Helper() + + return newTestSDKContext(t).WithGasMeter(storetypes.NewGasMeter(1_000_000)) +} + +// Cosmos-auth txs must stay on the wrapped SDK gas-accounting path. +// This confirms our custom decorator only changes Mina-mode gas accounting. +func TestRoutedSigGasConsumeDecoratorRoutesToCosmosDecorator(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newGasMeteredContext(t), TxAuthModeCosmos) + delegate := &recordingDecorator{} + nextCalled := false + + _, err := NewRoutedSigGasConsumeDecorator(validateSigCountAccountKeeper{}, delegate).AnteHandle( + ctx, + stubBasicTx{}, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.Equal(t, 1, delegate.calls) + require.True(t, nextCalled) +} + +// Mina-auth gas accounting requires SignatureV2 access from SigVerifiableTx. +// Plain sdk.Tx values should fail before any gas is consumed or delegated state changes happen. +func TestRoutedSigGasConsumeDecoratorRejectsNonSigVerifiableTxForMina(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newGasMeteredContext(t), TxAuthModeMina) + + _, err := NewRoutedSigGasConsumeDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubBasicTx{}, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called for invalid Mina tx types") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, sdkerrors.ErrTxDecode) + require.ErrorContains(t, err, "invalid transaction type") + require.Zero(t, ctx.GasMeter().GasConsumed()) +} + +// Signature loading failures belong to the tx and should bubble up unchanged. +// Failing before gas consumption keeps accounting aligned with actual verification work. +func TestRoutedSigGasConsumeDecoratorPropagatesGetSignaturesError(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newGasMeteredContext(t), TxAuthModeMina) + expectedErr := errSigGasTestSignaturesUnavailable + + _, err := NewRoutedSigGasConsumeDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubSigVerifiableTx{sigsErr: expectedErr}, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called when signatures cannot be loaded") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, expectedErr) + require.Zero(t, ctx.GasMeter().GasConsumed()) +} + +// Each Mina single signature should consume the configured verify gas cost. +// We assert on the delta because the absolute meter value is an implementation detail. +func TestRoutedSigGasConsumeDecoratorConsumesGasForSingleSignatures(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newGasMeteredContext(t), TxAuthModeMina) + nextCalled := false + params := authtypes.DefaultParams() + params.SigVerifyCostSecp256k1 = 37 + before := ctx.GasMeter().GasConsumed() + + _, err := NewRoutedSigGasConsumeDecorator( + validateSigCountAccountKeeper{params: params}, + &recordingDecorator{}, + ).AnteHandle( + ctx, + stubSigVerifiableTx{ + sigs: []signingtypes.SignatureV2{ + {Data: &signingtypes.SingleSignatureData{}}, + {Data: &signingtypes.SingleSignatureData{}}, + }, + }, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.True(t, nextCalled) + // Only the verification work added by this decorator matters, so we compare before/after. + require.Equal(t, uint64(2*params.SigVerifyCostSecp256k1), ctx.GasMeter().GasConsumed()-before) +} + +// An empty signature list is still a valid zero-iteration path for this decorator. +// This locks down the "no signatures to charge" branch without inventing special-case logic. +func TestRoutedSigGasConsumeDecoratorConsumesGasForEmptySignatureList(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newGasMeteredContext(t), TxAuthModeMina) + nextCalled := false + before := ctx.GasMeter().GasConsumed() + + _, err := NewRoutedSigGasConsumeDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubSigVerifiableTx{sigs: nil}, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.True(t, nextCalled) + require.Equal(t, before, ctx.GasMeter().GasConsumed()) +} + +// Mina-auth explicitly rejects multisig payloads in the gas-accounting stage too. +// This keeps gas accounting aligned with the same signature-shape restrictions as verification. +func TestRoutedSigGasConsumeDecoratorRejectsMultiSignatureData(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newGasMeteredContext(t), TxAuthModeMina) + + _, err := NewRoutedSigGasConsumeDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubSigVerifiableTx{ + sigs: []signingtypes.SignatureV2{ + {Data: &signingtypes.MultiSignatureData{}}, + }, + }, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called for multisig Mina transactions") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, sdkerrors.ErrInvalidType) + require.ErrorContains(t, err, "mina transactions do not support multisig signatures") + require.Zero(t, ctx.GasMeter().GasConsumed()) +} + +// Any unsupported SignatureV2 payload should stop gas accounting immediately. +// A nil payload is the smallest stable input for the default branch. +func TestRoutedSigGasConsumeDecoratorRejectsUnexpectedSignatureDataType(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newGasMeteredContext(t), TxAuthModeMina) + + _, err := NewRoutedSigGasConsumeDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubSigVerifiableTx{ + sigs: []signingtypes.SignatureV2{ + {Data: nil}, + }, + }, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called for unsupported signature payloads") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, sdkerrors.ErrInvalidType) + require.ErrorContains(t, err, "unexpected signature data type ") + require.Zero(t, ctx.GasMeter().GasConsumed()) +} + +// Gas should be charged for valid signatures processed before the first invalid one. +// This confirms the decorator accounts for completed work even when it later aborts. +func TestRoutedSigGasConsumeDecoratorStopsGasAccountingAtFirstInvalidSignature(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newGasMeteredContext(t), TxAuthModeMina) + params := authtypes.DefaultParams() + params.SigVerifyCostSecp256k1 = 41 + before := ctx.GasMeter().GasConsumed() + + _, err := NewRoutedSigGasConsumeDecorator( + validateSigCountAccountKeeper{params: params}, + &recordingDecorator{}, + ).AnteHandle( + ctx, + stubSigVerifiableTx{ + sigs: []signingtypes.SignatureV2{ + {Data: &signingtypes.SingleSignatureData{}}, + {Data: &signingtypes.MultiSignatureData{}}, + }, + }, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called after an invalid signature payload") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, sdkerrors.ErrInvalidType) + require.ErrorContains(t, err, "mina transactions do not support multisig signatures") + // The second signature is invalid, so only the first one should have consumed verification gas. + require.Equal(t, uint64(params.SigVerifyCostSecp256k1), ctx.GasMeter().GasConsumed()-before) +} diff --git a/app/ante/routed_sigverify_test.go b/app/ante/routed_sigverify_test.go new file mode 100644 index 00000000..6a8227dd --- /dev/null +++ b/app/ante/routed_sigverify_test.go @@ -0,0 +1,83 @@ +package ante + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" +) + +// Cosmos-auth txs should stay on the wrapped SDK signature-verification path. +// This keeps Mina verification logic isolated to Mina mode only. +func TestRoutedSigVerificationDecoratorRoutesToCosmosDecorator(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeCosmos) + delegate := &recordingDecorator{} + verifier := &recordingVerifier{} + nextCalled := false + + _, err := NewRoutedSigVerificationDecorator(delegate, verifier).AnteHandle( + ctx, + nil, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.Equal(t, 1, delegate.calls) + require.Zero(t, verifier.calls) + require.True(t, nextCalled) +} + +// Mina-auth txs must be verified by the custom Mina verifier instead of the SDK decorator. +// A successful verification should continue to the next ante handler. +func TestRoutedSigVerificationDecoratorRoutesToMinaVerifier(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + delegate := &recordingDecorator{} + verifier := &recordingVerifier{} + nextCalled := false + + _, err := NewRoutedSigVerificationDecorator(delegate, verifier).AnteHandle( + ctx, + nil, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.Zero(t, delegate.calls) + require.Equal(t, 1, verifier.calls) + require.True(t, nextCalled) +} + +// Mina verifier failures must abort the ante chain immediately. +// This preserves the verifier error rather than masking it in routing code. +func TestRoutedSigVerificationDecoratorPropagatesVerifierErrors(t *testing.T) { + t.Parallel() + + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + + _, err := NewRoutedSigVerificationDecorator( + &recordingDecorator{}, + &recordingVerifier{err: errVerifyFailed}, + ).AnteHandle( + ctx, + nil, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called when verifier fails") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, errVerifyFailed) +} diff --git a/app/ante/routed_validate_sigcount_test.go b/app/ante/routed_validate_sigcount_test.go new file mode 100644 index 00000000..3997cc0a --- /dev/null +++ b/app/ante/routed_validate_sigcount_test.go @@ -0,0 +1,243 @@ +package ante + +import ( + "context" + "errors" + "testing" + "time" + + "cosmossdk.io/core/address" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/stretchr/testify/require" + protov2 "google.golang.org/protobuf/proto" +) + +type validateSigCountAccountKeeper struct { + params authtypes.Params +} + +// The routed sig-count tests only need auth params, not real account storage behavior. +func (k validateSigCountAccountKeeper) GetParams(context.Context) authtypes.Params { + if k.params.TxSigLimit == 0 { + return authtypes.DefaultParams() + } + + return k.params +} + +func (validateSigCountAccountKeeper) GetAccount(context.Context, sdk.AccAddress) sdk.AccountI { + return nil +} + +func (validateSigCountAccountKeeper) SetAccount(context.Context, sdk.AccountI) {} + +func (validateSigCountAccountKeeper) GetModuleAddress(string) sdk.AccAddress { return nil } + +func (validateSigCountAccountKeeper) AddressCodec() address.Codec { return nil } + +func (validateSigCountAccountKeeper) UnorderedTransactionsEnabled() bool { return false } + +func (validateSigCountAccountKeeper) RemoveExpiredUnorderedNonces(sdk.Context) error { return nil } + +func (validateSigCountAccountKeeper) TryAddUnorderedNonce(sdk.Context, []byte, time.Time) error { + return nil +} + +type stubBasicTx struct{} + +func (stubBasicTx) GetMsgs() []sdk.Msg { return nil } + +func (stubBasicTx) GetMsgsV2() ([]protov2.Message, error) { return nil, nil } + +func (stubBasicTx) ValidateBasic() error { return nil } + +// stubSigVerifiableTx exposes just enough SignatureV2 behavior to drive the Mina-specific branch. +type stubSigVerifiableTx struct { + stubBasicTx + sigs []signingtypes.SignatureV2 + sigsErr error +} + +func (tx stubSigVerifiableTx) GetSigners() ([][]byte, error) { return nil, nil } + +func (tx stubSigVerifiableTx) GetPubKeys() ([]cryptotypes.PubKey, error) { return nil, nil } + +func (tx stubSigVerifiableTx) GetSignaturesV2() ([]signingtypes.SignatureV2, error) { + if tx.sigsErr != nil { + return nil, tx.sigsErr + } + + return tx.sigs, nil +} + +// Cosmos-auth txs must bypass Mina-specific logic and use the wrapped SDK decorator. +// This confirms the custom decorator only changes Mina-mode behavior. +func TestRoutedValidateSigCountDecoratorRoutesToCosmosDecorator(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeCosmos) + delegate := &recordingDecorator{} + nextCalled := false + + _, err := NewRoutedValidateSigCountDecorator(validateSigCountAccountKeeper{}, delegate).AnteHandle( + ctx, + stubBasicTx{}, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.Equal(t, 1, delegate.calls) + require.True(t, nextCalled) +} + +// Mina-auth txs must implement SigVerifiableTx because the decorator inspects SignatureV2 entries. +// A plain sdk.Tx should fail immediately before any custom counting logic runs. +func TestRoutedValidateSigCountDecoratorRejectsNonSigVerifiableTxForMina(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + + _, err := NewRoutedValidateSigCountDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubBasicTx{}, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called for invalid Mina tx types") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, sdkerrors.ErrTxDecode) + require.ErrorContains(t, err, "Tx must be a sigTx") +} + +// Signature extraction errors come from the tx itself and should bubble up unchanged. +// Stopping early here keeps routing code from masking lower-level tx problems. +func TestRoutedValidateSigCountDecoratorPropagatesGetSignaturesError(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + expectedErr := errors.New("signatures unavailable") + + _, err := NewRoutedValidateSigCountDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubSigVerifiableTx{sigsErr: expectedErr}, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called when signatures cannot be loaded") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, expectedErr) +} + +// Mina-auth txs with only single signatures and a sufficient limit should pass through. +// This is the happy path for the custom Mina-specific signature counting rules. +func TestRoutedValidateSigCountDecoratorAllowsSingleSignaturesWithinLimit(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + nextCalled := false + + _, err := NewRoutedValidateSigCountDecorator( + validateSigCountAccountKeeper{params: authtypes.Params{TxSigLimit: 2}}, + &recordingDecorator{}, + ).AnteHandle( + ctx, + stubSigVerifiableTx{ + sigs: []signingtypes.SignatureV2{ + {Data: &signingtypes.SingleSignatureData{}}, + {Data: &signingtypes.SingleSignatureData{}}, + }, + }, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return nextCtx, nil + }, + ) + + require.NoError(t, err) + require.True(t, nextCalled) +} + +// Mina-auth explicitly does not support multisig payloads in this chain. +// Rejecting here prevents later decorators from assuming unsupported signature structure. +func TestRoutedValidateSigCountDecoratorRejectsMultiSignatureData(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + + _, err := NewRoutedValidateSigCountDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubSigVerifiableTx{ + sigs: []signingtypes.SignatureV2{ + {Data: &signingtypes.MultiSignatureData{}}, + }, + }, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called for multisig Mina transactions") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, sdkerrors.ErrInvalidType) + require.ErrorContains(t, err, "mina transactions do not support multisig signatures") +} + +// Any non-single, non-multisig SignatureV2 payload should fail fast. +// A nil payload is the smallest stable way to cover the default branch. +func TestRoutedValidateSigCountDecoratorRejectsUnexpectedSignatureDataType(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + + _, err := NewRoutedValidateSigCountDecorator(validateSigCountAccountKeeper{}, &recordingDecorator{}).AnteHandle( + ctx, + stubSigVerifiableTx{ + sigs: []signingtypes.SignatureV2{ + {Data: nil}, + }, + }, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called for unsupported signature payloads") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, sdkerrors.ErrInvalidType) + require.ErrorContains(t, err, "unexpected signature data type ") +} + +// The decorator must enforce the chain TxSigLimit even on the Mina path. +// Exceeding the limit should stop the ante chain before any later verification runs. +func TestRoutedValidateSigCountDecoratorRejectsSignatureCountAboveLimit(t *testing.T) { + t.Parallel() + ctx := setTxAuthMode(newTestSDKContext(t), TxAuthModeMina) + + _, err := NewRoutedValidateSigCountDecorator( + validateSigCountAccountKeeper{params: authtypes.Params{TxSigLimit: 1}}, + &recordingDecorator{}, + ).AnteHandle( + ctx, + stubSigVerifiableTx{ + sigs: []signingtypes.SignatureV2{ + {Data: &signingtypes.SingleSignatureData{}}, + {Data: &signingtypes.SingleSignatureData{}}, + }, + }, + false, + func(nextCtx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + t.Fatal("next handler should not be called when the signature limit is exceeded") + return nextCtx, nil + }, + ) + + require.ErrorIs(t, err, sdkerrors.ErrTooManySignatures) + require.ErrorContains(t, err, "signatures: 2, limit: 1") +} diff --git a/app/ante/test_helpers_test.go b/app/ante/test_helpers_test.go index a62cd9d2..4c789408 100644 --- a/app/ante/test_helpers_test.go +++ b/app/ante/test_helpers_test.go @@ -1,6 +1,7 @@ package ante import ( + "errors" "testing" "cosmossdk.io/log" @@ -8,6 +9,8 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" ) +// newTestSDKContext provides a stable default context for ante unit tests. +// Individual tests override only the flags or meters that matter for their branch. func newTestSDKContext(tb testing.TB) sdk.Context { tb.Helper() @@ -16,3 +19,31 @@ func newTestSDKContext(tb testing.TB) sdk.Context { Height: 1, }, false, log.NewNopLogger()) } + +// recordingDecorator tracks whether a routed decorator delegated to the wrapped SDK path. +type recordingDecorator struct { + calls int +} + +func (d *recordingDecorator) AnteHandle( + ctx sdk.Context, + tx sdk.Tx, + simulate bool, + next sdk.AnteHandler, +) (sdk.Context, error) { + d.calls++ + return next(ctx, tx, simulate) +} + +// recordingVerifier tracks whether the Mina verification branch was invoked. +type recordingVerifier struct { + calls int + err error +} + +func (v *recordingVerifier) VerifySignatures(ctx sdk.Context, tx sdk.Tx, simulate bool) error { + v.calls++ + return v.err +} + +var errVerifyFailed = errors.New("verify failed") diff --git a/app/ante/tx_mode_test.go b/app/ante/tx_mode_test.go index 9ac8c9bc..1c16d2b0 100644 --- a/app/ante/tx_mode_test.go +++ b/app/ante/tx_mode_test.go @@ -25,6 +25,8 @@ func (tx stubExtensionTx) GetExtensionOptions() []*codectypes.Any { return tx.extensionOptions } +// mustTxAuthModeExtensionAny builds the exact Any payload consumed by the resolver. +// Using real proto bytes keeps these tests close to the on-wire tx representation. func mustTxAuthModeExtensionAny(t *testing.T, mode antetypes.TxAuthMode) *codectypes.Any { t.Helper() @@ -37,6 +39,8 @@ func mustTxAuthModeExtensionAny(t *testing.T, mode antetypes.TxAuthMode) *codect } } +// Txs without the custom extension should stay on the default Cosmos path. +// This preserves compatibility with normal SDK transactions. func TestResolveTxAuthModeDefaultsToCosmos(t *testing.T) { t.Parallel() @@ -46,6 +50,8 @@ func TestResolveTxAuthModeDefaultsToCosmos(t *testing.T) { require.Equal(t, TxAuthModeCosmos, mode) } +// A Mina extension must flip resolution into Mina mode. +// This is the primary positive branch for custom auth-mode routing. func TestResolveTxAuthModeReadsMinaExtension(t *testing.T) { t.Parallel() @@ -59,6 +65,8 @@ func TestResolveTxAuthModeReadsMinaExtension(t *testing.T) { require.Equal(t, TxAuthModeMina, mode) } +// Malformed extension bytes should surface a decoding error instead of being ignored. +// Returning an error here protects the ante chain from ambiguous tx metadata. func TestResolveTxAuthModeRejectsMalformedExtension(t *testing.T) { t.Parallel() @@ -72,6 +80,8 @@ func TestResolveTxAuthModeRejectsMalformedExtension(t *testing.T) { require.Equal(t, TxAuthModeCosmos, mode) } +// Multiple auth-mode extensions are ambiguous and must be rejected. +// This prevents a single tx from carrying conflicting auth instructions. func TestResolveTxAuthModeRejectsMultipleExtensions(t *testing.T) { t.Parallel() @@ -86,6 +96,8 @@ func TestResolveTxAuthModeRejectsMultipleExtensions(t *testing.T) { require.Equal(t, TxAuthModeCosmos, mode) } +// The extension checker must accept our custom type while still delegating all other checks. +// That keeps the custom wiring composable with any additional extension checkers above it. func TestNewTxAuthExtensionOptionCheckerAcceptsCustomType(t *testing.T) { t.Parallel() @@ -101,6 +113,8 @@ func TestNewTxAuthExtensionOptionCheckerAcceptsCustomType(t *testing.T) { require.False(t, checker(&codectypes.Any{TypeUrl: "/other.Extension"})) } +// The decorator should resolve auth mode once and store it on the context. +// Downstream routed decorators rely on this cached value instead of reparsing the tx. func TestTxAuthModeDecoratorStoresResolvedMode(t *testing.T) { t.Parallel() @@ -115,6 +129,7 @@ func TestTxAuthModeDecoratorStoresResolvedMode(t *testing.T) { nextCalled := false next := func(nextCtx sdk.Context, nextTx sdk.Tx, simulate bool) (sdk.Context, error) { + // This is the observable contract of the decorator: downstream handlers can read the mode from context. nextCalled = true mode, ok := GetTxAuthMode(nextCtx) require.True(t, ok)