From c8f4cf787b0904486fd70b0f1d14a8ee6bb9d476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20Toledano?= Date: Thu, 3 Oct 2024 14:45:10 +0200 Subject: [PATCH] feat(client/v2): factory (#20623) Co-authored-by: Julien Robert --- client/v2/CHANGELOG.md | 2 + client/v2/autocli/flag/address.go | 2 +- client/v2/autocli/keyring/interface.go | 6 + client/v2/autocli/keyring/keyring.go | 10 + client/v2/autocli/keyring/no_keyring.go | 8 + client/v2/go.mod | 2 +- client/v2/internal/account/retriever.go | 116 +++ client/v2/internal/coins/util.go | 66 ++ client/v2/internal/coins/util_test.go | 83 +++ client/v2/offchain/sign.go | 2 +- client/v2/tx/README.md | 465 ++++++++++++ client/v2/tx/common_test.go | 116 +++ client/v2/tx/config.go | 338 +++++++++ client/v2/tx/config_test.go | 293 ++++++++ client/v2/tx/encoder.go | 119 +++ client/v2/tx/encoder_test.go | 107 +++ client/v2/tx/factory.go | 761 ++++++++++++++++++++ client/v2/tx/factory_test.go | 921 ++++++++++++++++++++++++ client/v2/tx/flags.go | 52 ++ client/v2/tx/signature.go | 197 +++++ client/v2/tx/signature_test.go | 143 ++++ client/v2/tx/tx.go | 229 ++++++ client/v2/tx/types.go | 214 ++++++ client/v2/tx/wrapper.go | 115 +++ crypto/keyring/autocli.go | 49 +- 25 files changed, 4411 insertions(+), 5 deletions(-) create mode 100644 client/v2/internal/account/retriever.go create mode 100644 client/v2/internal/coins/util.go create mode 100644 client/v2/internal/coins/util_test.go create mode 100644 client/v2/tx/README.md create mode 100644 client/v2/tx/common_test.go create mode 100644 client/v2/tx/config.go create mode 100644 client/v2/tx/config_test.go create mode 100644 client/v2/tx/encoder.go create mode 100644 client/v2/tx/encoder_test.go create mode 100644 client/v2/tx/factory.go create mode 100644 client/v2/tx/factory_test.go create mode 100644 client/v2/tx/flags.go create mode 100644 client/v2/tx/signature.go create mode 100644 client/v2/tx/signature_test.go create mode 100644 client/v2/tx/tx.go create mode 100644 client/v2/tx/types.go create mode 100644 client/v2/tx/wrapper.go diff --git a/client/v2/CHANGELOG.md b/client/v2/CHANGELOG.md index dfd4b8cfb213..a3702845c575 100644 --- a/client/v2/CHANGELOG.md +++ b/client/v2/CHANGELOG.md @@ -42,6 +42,8 @@ Ref: https://keepachangelog.com/en/1.0.0/ * [#18626](https://github.com/cosmos/cosmos-sdk/pull/18626) Support for off-chain signing and verification of a file. * [#18461](https://github.com/cosmos/cosmos-sdk/pull/18461) Support governance proposals. +* [#20623](https://github.com/cosmos/cosmos-sdk/pull/20623) Introduce client/v2 tx factory. +* [#20623](https://github.com/cosmos/cosmos-sdk/pull/20623) Extend client/v2 keyring interface with `KeyType` and `KeyInfo`. ### Improvements diff --git a/client/v2/autocli/flag/address.go b/client/v2/autocli/flag/address.go index 58108d094990..454c30a317dd 100644 --- a/client/v2/autocli/flag/address.go +++ b/client/v2/autocli/flag/address.go @@ -151,7 +151,7 @@ func getKeyringFromCtx(ctx *context.Context) keyring.Keyring { dctx := *ctx if dctx != nil { if clientCtx := dctx.Value(client.ClientContextKey); clientCtx != nil { - k, err := sdkkeyring.NewAutoCLIKeyring(clientCtx.(*client.Context).Keyring) + k, err := sdkkeyring.NewAutoCLIKeyring(clientCtx.(*client.Context).Keyring, clientCtx.(*client.Context).AddressCodec) if err != nil { panic(fmt.Errorf("failed to create keyring: %w", err)) } diff --git a/client/v2/autocli/keyring/interface.go b/client/v2/autocli/keyring/interface.go index fa448bd20599..7f2fee1b3e3d 100644 --- a/client/v2/autocli/keyring/interface.go +++ b/client/v2/autocli/keyring/interface.go @@ -20,4 +20,10 @@ type Keyring interface { // Sign signs the given bytes with the key with the given name. Sign(name string, msg []byte, signMode signingv1beta1.SignMode) ([]byte, error) + + // KeyType returns the type of the key. + KeyType(name string) (uint, error) + + // KeyInfo given a key name or address returns key name, key address and key type. + KeyInfo(nameOrAddr string) (string, string, uint, error) } diff --git a/client/v2/autocli/keyring/keyring.go b/client/v2/autocli/keyring/keyring.go index 70c2d27d08ed..f5dce25efceb 100644 --- a/client/v2/autocli/keyring/keyring.go +++ b/client/v2/autocli/keyring/keyring.go @@ -48,3 +48,13 @@ func (k *KeyringImpl) LookupAddressByKeyName(name string) ([]byte, error) { func (k *KeyringImpl) Sign(name string, msg []byte, signMode signingv1beta1.SignMode) ([]byte, error) { return k.k.Sign(name, msg, signMode) } + +// KeyType returns the type of the key. +func (k *KeyringImpl) KeyType(name string) (uint, error) { + return k.k.KeyType(name) +} + +// KeyInfo given a key name or address returns key name, key address and key type. +func (k *KeyringImpl) KeyInfo(nameOrAddr string) (string, string, uint, error) { + return k.k.KeyInfo(nameOrAddr) +} diff --git a/client/v2/autocli/keyring/no_keyring.go b/client/v2/autocli/keyring/no_keyring.go index e14267cee5e3..7f0be9c7593e 100644 --- a/client/v2/autocli/keyring/no_keyring.go +++ b/client/v2/autocli/keyring/no_keyring.go @@ -29,3 +29,11 @@ func (k NoKeyring) GetPubKey(name string) (cryptotypes.PubKey, error) { func (k NoKeyring) Sign(name string, msg []byte, signMode signingv1beta1.SignMode) ([]byte, error) { return nil, errNoKeyring } + +func (k NoKeyring) KeyType(name string) (uint, error) { + return 0, errNoKeyring +} + +func (k NoKeyring) KeyInfo(name string) (string, string, uint, error) { + return "", "", 0, errNoKeyring +} diff --git a/client/v2/go.mod b/client/v2/go.mod index 8a9d7bd03c11..b999f9c15ba5 100644 --- a/client/v2/go.mod +++ b/client/v2/go.mod @@ -53,7 +53,7 @@ require ( github.com/cosmos/btcutil v1.0.5 // indirect github.com/cosmos/cosmos-db v1.0.3-0.20240911104526-ddc3f09bfc22 // indirect github.com/cosmos/crypto v0.1.2 // indirect - github.com/cosmos/go-bip39 v1.0.0 // indirect + github.com/cosmos/go-bip39 v1.0.0 github.com/cosmos/gogogateway v1.2.0 // indirect github.com/cosmos/gogoproto v1.7.0 github.com/cosmos/iavl v1.3.0 // indirect diff --git a/client/v2/internal/account/retriever.go b/client/v2/internal/account/retriever.go new file mode 100644 index 000000000000..2cef69f92cf8 --- /dev/null +++ b/client/v2/internal/account/retriever.go @@ -0,0 +1,116 @@ +package account + +import ( + "context" + "fmt" + "strconv" + + gogogrpc "github.com/cosmos/gogoproto/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "cosmossdk.io/core/address" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" +) + +// GRPCBlockHeightHeader represents the gRPC header for block height. +const GRPCBlockHeightHeader = "x-cosmos-block-height" + +var _ AccountRetriever = accountRetriever{} + +// Account provides a read-only abstraction over the auth module's AccountI. +type Account interface { + GetAddress() sdk.AccAddress + GetPubKey() cryptotypes.PubKey // can return nil. + GetAccountNumber() uint64 + GetSequence() uint64 +} + +// AccountRetriever defines methods required to retrieve account details necessary for transaction signing. +type AccountRetriever interface { + GetAccount(context.Context, []byte) (Account, error) + GetAccountWithHeight(context.Context, []byte) (Account, int64, error) + EnsureExists(context.Context, []byte) error + GetAccountNumberSequence(context.Context, []byte) (accNum, accSeq uint64, err error) +} + +type accountRetriever struct { + ac address.Codec + conn gogogrpc.ClientConn + registry codectypes.InterfaceRegistry +} + +// NewAccountRetriever creates a new instance of accountRetriever. +func NewAccountRetriever(ac address.Codec, conn gogogrpc.ClientConn, registry codectypes.InterfaceRegistry) *accountRetriever { + return &accountRetriever{ + ac: ac, + conn: conn, + registry: registry, + } +} + +// GetAccount retrieves an account using its address. +func (a accountRetriever) GetAccount(ctx context.Context, addr []byte) (Account, error) { + acc, _, err := a.GetAccountWithHeight(ctx, addr) + return acc, err +} + +// GetAccountWithHeight retrieves an account and its associated block height using the account's address. +func (a accountRetriever) GetAccountWithHeight(ctx context.Context, addr []byte) (Account, int64, error) { + var header metadata.MD + qc := authtypes.NewQueryClient(a.conn) + + addrStr, err := a.ac.BytesToString(addr) + if err != nil { + return nil, 0, err + } + + res, err := qc.Account(ctx, &authtypes.QueryAccountRequest{Address: addrStr}, grpc.Header(&header)) + if err != nil { + return nil, 0, err + } + + blockHeight := header.Get(GRPCBlockHeightHeader) + if len(blockHeight) != 1 { + return nil, 0, fmt.Errorf("unexpected '%s' header length; got %d, expected 1", GRPCBlockHeightHeader, len(blockHeight)) + } + + nBlockHeight, err := strconv.Atoi(blockHeight[0]) + if err != nil { + return nil, 0, fmt.Errorf("failed to parse block height: %w", err) + } + + var acc Account + if err := a.registry.UnpackAny(res.Account, &acc); err != nil { + return nil, 0, err + } + + return acc, int64(nBlockHeight), nil +} + +// EnsureExists checks if an account exists using its address. +func (a accountRetriever) EnsureExists(ctx context.Context, addr []byte) error { + if _, err := a.GetAccount(ctx, addr); err != nil { + return err + } + return nil +} + +// GetAccountNumberSequence retrieves the account number and sequence for an account using its address. +func (a accountRetriever) GetAccountNumberSequence(ctx context.Context, addr []byte) (accNum, accSeq uint64, err error) { + acc, err := a.GetAccount(ctx, addr) + if err != nil { + if status.Code(err) == codes.NotFound { + return 0, 0, nil + } + return 0, 0, err + } + + return acc.GetAccountNumber(), acc.GetSequence(), nil +} diff --git a/client/v2/internal/coins/util.go b/client/v2/internal/coins/util.go new file mode 100644 index 000000000000..1495386713f6 --- /dev/null +++ b/client/v2/internal/coins/util.go @@ -0,0 +1,66 @@ +package coins + +import ( + "errors" + + base "cosmossdk.io/api/cosmos/base/v1beta1" + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +var ( + _ withAmount = &base.Coin{} + _ withAmount = &base.DecCoin{} +) + +type withAmount interface { + GetAmount() string +} + +// IsZero check if given coins are zero. +func IsZero[T withAmount](coins []T) (bool, error) { + for _, coin := range coins { + amount, ok := math.NewIntFromString(coin.GetAmount()) + if !ok { + return false, errors.New("invalid coin amount") + } + if !amount.IsZero() { + return false, nil + } + } + return true, nil +} + +func ParseDecCoins(coins string) ([]*base.DecCoin, error) { + parsedGasPrices, err := sdk.ParseDecCoins(coins) // TODO: do it here to avoid sdk dependency + if err != nil { + return nil, err + } + + finalGasPrices := make([]*base.DecCoin, len(parsedGasPrices)) + for i, coin := range parsedGasPrices { + finalGasPrices[i] = &base.DecCoin{ + Denom: coin.Denom, + Amount: coin.Amount.String(), + } + } + return finalGasPrices, nil +} + +func ParseCoinsNormalized(coins string) ([]*base.Coin, error) { + parsedFees, err := sdk.ParseCoinsNormalized(coins) // TODO: do it here to avoid sdk dependency + if err != nil { + return nil, err + } + + finalFees := make([]*base.Coin, len(parsedFees)) + for i, coin := range parsedFees { + finalFees[i] = &base.Coin{ + Denom: coin.Denom, + Amount: coin.Amount.String(), + } + } + + return finalFees, nil +} diff --git a/client/v2/internal/coins/util_test.go b/client/v2/internal/coins/util_test.go new file mode 100644 index 000000000000..1ee7f5842920 --- /dev/null +++ b/client/v2/internal/coins/util_test.go @@ -0,0 +1,83 @@ +package coins + +import ( + "testing" + + "github.com/stretchr/testify/require" + + base "cosmossdk.io/api/cosmos/base/v1beta1" +) + +func TestCoinIsZero(t *testing.T) { + type testCase[T withAmount] struct { + name string + coins []T + isZero bool + } + tests := []testCase[*base.Coin]{ + { + name: "not zero coin", + coins: []*base.Coin{ + { + Denom: "stake", + Amount: "100", + }, + }, + isZero: false, + }, + { + name: "zero coin", + coins: []*base.Coin{ + { + Denom: "stake", + Amount: "0", + }, + }, + isZero: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IsZero(tt.coins) + require.NoError(t, err) + require.Equal(t, got, tt.isZero) + }) + } +} + +func TestDecCoinIsZero(t *testing.T) { + type testCase[T withAmount] struct { + name string + coins []T + isZero bool + } + tests := []testCase[*base.DecCoin]{ + { + name: "not zero coin", + coins: []*base.DecCoin{ + { + Denom: "stake", + Amount: "100", + }, + }, + isZero: false, + }, + { + name: "zero coin", + coins: []*base.DecCoin{ + { + Denom: "stake", + Amount: "0", + }, + }, + isZero: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IsZero(tt.coins) + require.NoError(t, err) + require.Equal(t, got, tt.isZero) + }) + } +} diff --git a/client/v2/offchain/sign.go b/client/v2/offchain/sign.go index fd01227ed13e..b36a50c00ca0 100644 --- a/client/v2/offchain/sign.go +++ b/client/v2/offchain/sign.go @@ -63,7 +63,7 @@ func Sign(ctx client.Context, rawBytes []byte, fromName, indent, encoding, outpu // sign signs a digest with provided key and SignMode. func sign(ctx client.Context, fromName, digest string) (*apitx.Tx, error) { - keybase, err := keyring.NewAutoCLIKeyring(ctx.Keyring) + keybase, err := keyring.NewAutoCLIKeyring(ctx.Keyring, ctx.AddressCodec) if err != nil { return nil, err } diff --git a/client/v2/tx/README.md b/client/v2/tx/README.md new file mode 100644 index 000000000000..ffe2fb8fda00 --- /dev/null +++ b/client/v2/tx/README.md @@ -0,0 +1,465 @@ +The tx package provides a robust set of tools for building, signing, and managing transactions in a Cosmos SDK-based blockchain application. + +## Overview + +This package includes several key components: + +1. Transaction Factory +2. Transaction Config +3. Transaction Encoder/Decoder +4. Signature Handling + +## Architecture + +```mermaid +graph TD + A[Client] --> B[Factory] + B --> D[TxConfig] + D --> E[TxEncodingConfig] + D --> F[TxSigningConfig] + B --> G[Tx] + G --> H[Encoder] + G --> I[Decoder] + F --> J[SignModeHandler] + F --> K[SigningContext] + B --> L[AuxTxBuilder] +``` + +## Key Components + +### TxConfig + +`TxConfig` provides configuration for transaction handling, including: + +- Encoding and decoding +- Sign mode handling +- Signature JSON marshaling/unmarshaling + +```mermaid +classDiagram + class TxConfig { + <> + TxEncodingConfig + TxSigningConfig + } + + class TxEncodingConfig { + <> + TxEncoder() txEncoder + TxDecoder() txDecoder + TxJSONEncoder() txEncoder + TxJSONDecoder() txDecoder + Decoder() Decoder + } + + class TxSigningConfig { + <> + SignModeHandler() *signing.HandlerMap + SigningContext() *signing.Context + MarshalSignatureJSON([]Signature) ([]byte, error) + UnmarshalSignatureJSON([]byte) ([]Signature, error) + } + + class txConfig { + TxEncodingConfig + TxSigningConfig + } + + class defaultEncodingConfig { + cdc codec.BinaryCodec + decoder Decoder + TxEncoder() txEncoder + TxDecoder() txDecoder + TxJSONEncoder() txEncoder + TxJSONDecoder() txDecoder + } + + class defaultTxSigningConfig { + signingCtx *signing.Context + handlerMap *signing.HandlerMap + cdc codec.BinaryCodec + SignModeHandler() *signing.HandlerMap + SigningContext() *signing.Context + MarshalSignatureJSON([]Signature) ([]byte, error) + UnmarshalSignatureJSON([]byte) ([]Signature, error) + } + + TxConfig <|-- txConfig + TxEncodingConfig <|.. defaultEncodingConfig + TxSigningConfig <|.. defaultTxSigningConfig + txConfig *-- defaultEncodingConfig + txConfig *-- defaultTxSigningConfig +``` + +### Factory + +The `Factory` is the main entry point for creating and managing transactions. It handles: + +- Account preparation +- Gas calculation +- Unsigned transaction building +- Transaction signing +- Transaction simulation +- Transaction broadcasting + +```mermaid +classDiagram + class Factory { + keybase keyring.Keyring + cdc codec.BinaryCodec + accountRetriever account.AccountRetriever + ac address.Codec + conn gogogrpc.ClientConn + txConfig TxConfig + txParams TxParameters + tx txState + + NewFactory(keybase, cdc, accRetriever, txConfig, ac, conn, parameters) Factory + Prepare() error + BuildUnsignedTx(msgs ...transaction.Msg) error + BuildsSignedTx(ctx context.Context, msgs ...transaction.Msg) (Tx, error) + calculateGas(msgs ...transaction.Msg) error + Simulate(msgs ...transaction.Msg) (*apitx.SimulateResponse, uint64, error) + UnsignedTxString(msgs ...transaction.Msg) (string, error) + BuildSimTx(msgs ...transaction.Msg) ([]byte, error) + sign(ctx context.Context, overwriteSig bool) (Tx, error) + WithGas(gas uint64) + WithSequence(sequence uint64) + WithAccountNumber(accnum uint64) + getTx() (Tx, error) + getFee() (*apitx.Fee, error) + getSigningTxData() (signing.TxData, error) + setSignatures(...Signature) error + } + + class TxParameters { + <> + chainID string + AccountConfig + GasConfig + FeeConfig + SignModeConfig + TimeoutConfig + MemoConfig + } + + class TxConfig { + <> + } + + class Tx { + <> + } + + class txState { + <> + msgs []transaction.Msg + memo string + fees []*base.Coin + gasLimit uint64 + feeGranter []byte + feePayer []byte + timeoutHeight uint64 + unordered bool + timeoutTimestamp uint64 + signatures []Signature + signerInfos []*apitx.SignerInfo + } + + Factory *-- TxParameters + Factory *-- TxConfig + Factory *-- txState + Factory ..> Tx : creates +``` + +### Encoder/Decoder + +The package includes functions for encoding and decoding transactions in both binary and JSON formats. + +```mermaid +classDiagram + class Decoder { + <> + Decode(txBytes []byte) (*txdecode.DecodedTx, error) + } + + class txDecoder { + <> + decode(txBytes []byte) (Tx, error) + } + + class txEncoder { + <> + encode(tx Tx) ([]byte, error) + } + + class EncoderUtils { + <> + decodeTx(cdc codec.BinaryCodec, decoder Decoder) txDecoder + encodeTx(tx Tx) ([]byte, error) + decodeJsonTx(cdc codec.BinaryCodec, decoder Decoder) txDecoder + encodeJsonTx(tx Tx) ([]byte, error) + protoTxBytes(tx *txv1beta1.Tx) ([]byte, error) + } + + class MarshalOptions { + <> + Deterministic bool + } + + class JSONMarshalOptions { + <> + Indent string + UseProtoNames bool + UseEnumNumbers bool + } + + Decoder <.. EncoderUtils : uses + txDecoder <.. EncoderUtils : creates + txEncoder <.. EncoderUtils : implements + EncoderUtils ..> MarshalOptions : uses + EncoderUtils ..> JSONMarshalOptions : uses +``` + +### Sequence Diagrams + +#### Generate Aux Signer Data +```mermaid +sequenceDiagram + participant User + participant GenerateOrBroadcastTxCLI + participant generateAuxSignerData + participant makeAuxSignerData + participant AuxTxBuilder + participant ctx.PrintProto + + User->>GenerateOrBroadcastTxCLI: Call with isAux flag + GenerateOrBroadcastTxCLI->>generateAuxSignerData: Call + + generateAuxSignerData->>makeAuxSignerData: Call + makeAuxSignerData->>AuxTxBuilder: NewAuxTxBuilder() + + makeAuxSignerData->>AuxTxBuilder: SetAddress(f.txParams.fromAddress) + + alt f.txParams.offline + makeAuxSignerData->>AuxTxBuilder: SetAccountNumber(f.AccountNumber()) + makeAuxSignerData->>AuxTxBuilder: SetSequence(f.Sequence()) + else + makeAuxSignerData->>f.accountRetriever: GetAccountNumberSequence() + makeAuxSignerData->>AuxTxBuilder: SetAccountNumber(accNum) + makeAuxSignerData->>AuxTxBuilder: SetSequence(seq) + end + + makeAuxSignerData->>AuxTxBuilder: SetMsgs(msgs...) + makeAuxSignerData->>AuxTxBuilder: SetSignMode(f.SignMode()) + + makeAuxSignerData->>f.keybase: GetPubKey(f.txParams.fromName) + makeAuxSignerData->>AuxTxBuilder: SetPubKey(pubKey) + + makeAuxSignerData->>AuxTxBuilder: SetChainID(f.txParams.chainID) + makeAuxSignerData->>AuxTxBuilder: GetSignBytes() + + makeAuxSignerData->>f.keybase: Sign(f.txParams.fromName, signBz, f.SignMode()) + makeAuxSignerData->>AuxTxBuilder: SetSignature(sig) + + makeAuxSignerData->>AuxTxBuilder: GetAuxSignerData() + AuxTxBuilder-->>makeAuxSignerData: Return AuxSignerData + makeAuxSignerData-->>generateAuxSignerData: Return AuxSignerData + + generateAuxSignerData->>ctx.PrintProto: Print AuxSignerData + ctx.PrintProto-->>GenerateOrBroadcastTxCLI: Return result + GenerateOrBroadcastTxCLI-->>User: Return result +``` + +#### Generate Only +```mermaid +sequenceDiagram + participant User + participant GenerateOrBroadcastTxCLI + participant generateOnly + participant Factory + participant ctx.PrintString + + User->>GenerateOrBroadcastTxCLI: Call with generateOnly flag + GenerateOrBroadcastTxCLI->>generateOnly: Call + + generateOnly->>Factory: Prepare() + alt Error in Prepare + Factory-->>generateOnly: Return error + generateOnly-->>GenerateOrBroadcastTxCLI: Return error + GenerateOrBroadcastTxCLI-->>User: Return error + end + + generateOnly->>Factory: UnsignedTxString(msgs...) + Factory->>Factory: BuildUnsignedTx(msgs...) + Factory->>Factory: setMsgs(msgs...) + Factory->>Factory: setMemo(f.txParams.memo) + Factory->>Factory: setFees(f.txParams.gasPrices) + Factory->>Factory: setGasLimit(f.txParams.gas) + Factory->>Factory: setFeeGranter(f.txParams.feeGranter) + Factory->>Factory: setFeePayer(f.txParams.feePayer) + Factory->>Factory: setTimeoutHeight(f.txParams.timeoutHeight) + + Factory->>Factory: getTx() + Factory->>Factory: txConfig.TxJSONEncoder() + Factory->>Factory: encoder(tx) + + Factory-->>generateOnly: Return unsigned tx string + generateOnly->>ctx.PrintString: Print unsigned tx string + ctx.PrintString-->>generateOnly: Return result + generateOnly-->>GenerateOrBroadcastTxCLI: Return result + GenerateOrBroadcastTxCLI-->>User: Return result +``` + +#### DryRun +```mermaid +sequenceDiagram + participant User + participant GenerateOrBroadcastTxCLI + participant dryRun + participant Factory + participant os.Stderr + + User->>GenerateOrBroadcastTxCLI: Call with dryRun flag + GenerateOrBroadcastTxCLI->>dryRun: Call + + dryRun->>Factory: Prepare() + alt Error in Prepare + Factory-->>dryRun: Return error + dryRun-->>GenerateOrBroadcastTxCLI: Return error + GenerateOrBroadcastTxCLI-->>User: Return error + end + + dryRun->>Factory: Simulate(msgs...) + Factory->>Factory: BuildSimTx(msgs...) + Factory->>Factory: BuildUnsignedTx(msgs...) + Factory->>Factory: getSimPK() + Factory->>Factory: getSimSignatureData(pk) + Factory->>Factory: setSignatures(sig) + Factory->>Factory: getTx() + Factory->>Factory: txConfig.TxEncoder()(tx) + + Factory->>ServiceClient: Simulate(context.Background(), &apitx.SimulateRequest{}) + ServiceClient->>Factory: Return result + + Factory-->>dryRun: Return (simulation, gas, error) + alt Error in Simulate + dryRun-->>GenerateOrBroadcastTxCLI: Return error + GenerateOrBroadcastTxCLI-->>User: Return error + end + + dryRun->>os.Stderr: Fprintf(GasEstimateResponse{GasEstimate: gas}) + os.Stderr-->>dryRun: Return result + dryRun-->>GenerateOrBroadcastTxCLI: Return result + GenerateOrBroadcastTxCLI-->>User: Return result +``` + +#### Generate and Broadcast Tx +```mermaid +sequenceDiagram + participant User + participant GenerateOrBroadcastTxCLI + participant BroadcastTx + participant Factory + participant clientCtx + + User->>GenerateOrBroadcastTxCLI: Call + GenerateOrBroadcastTxCLI->>BroadcastTx: Call + + BroadcastTx->>Factory: Prepare() + alt Error in Prepare + Factory-->>BroadcastTx: Return error + BroadcastTx-->>GenerateOrBroadcastTxCLI: Return error + GenerateOrBroadcastTxCLI-->>User: Return error + end + + alt SimulateAndExecute is true + BroadcastTx->>Factory: calculateGas(msgs...) + Factory->>Factory: Simulate(msgs...) + Factory->>Factory: WithGas(adjusted) + end + + BroadcastTx->>Factory: BuildUnsignedTx(msgs...) + Factory->>Factory: setMsgs(msgs...) + Factory->>Factory: setMemo(f.txParams.memo) + Factory->>Factory: setFees(f.txParams.gasPrices) + Factory->>Factory: setGasLimit(f.txParams.gas) + Factory->>Factory: setFeeGranter(f.txParams.feeGranter) + Factory->>Factory: setFeePayer(f.txParams.feePayer) + Factory->>Factory: setTimeoutHeight(f.txParams.timeoutHeight) + + alt !clientCtx.SkipConfirm + BroadcastTx->>Factory: getTx() + BroadcastTx->>Factory: txConfig.TxJSONEncoder() + BroadcastTx->>clientCtx: PrintRaw(txBytes) + BroadcastTx->>clientCtx: Input.GetConfirmation() + alt Not confirmed + BroadcastTx-->>GenerateOrBroadcastTxCLI: Return error + GenerateOrBroadcastTxCLI-->>User: Return error + end + end + + BroadcastTx->>Factory: BuildsSignedTx(ctx, msgs...) + Factory->>Factory: sign(ctx, true) + Factory->>Factory: keybase.GetPubKey(fromName) + Factory->>Factory: getSignBytesAdapter() + Factory->>Factory: keybase.Sign(fromName, bytesToSign, signMode) + Factory->>Factory: setSignatures(sig) + Factory->>Factory: getTx() + + BroadcastTx->>Factory: txConfig.TxEncoder() + BroadcastTx->>clientCtx: BroadcastTx(txBytes) + + alt Error in BroadcastTx + clientCtx-->>BroadcastTx: Return error + BroadcastTx-->>GenerateOrBroadcastTxCLI: Return error + GenerateOrBroadcastTxCLI-->>User: Return error + end + + BroadcastTx->>clientCtx: OutputTx(res) + clientCtx-->>BroadcastTx: Return result + BroadcastTx-->>GenerateOrBroadcastTxCLI: Return result + GenerateOrBroadcastTxCLI-->>User: Return result +``` + +## Usage + +To use the `tx` package, typically you would: + +1. Create a `Factory` +2. Simulate the transaction (optional) +3. Build a signed transaction +4. Encode the transaction +5. Broadcast the transaction + +Here's a simplified example: + +```go +// Create a Factory +factory, err := NewFactory(keybase, cdc, accountRetriever, txConfig, addressCodec, conn, txParameters) +if err != nil { + return err +} + +// Simulate the transaction (optional) +simRes, gas, err := factory.Simulate(msgs...) +if err != nil { + return err +} +factory.WithGas(gas) + +// Build a signed transaction +signedTx, err := factory.BuildsSignedTx(context.Background(), msgs...) +if err != nil { + return err +} + +// Encode the transaction +txBytes, err := factory.txConfig.TxEncoder()(signedTx) +if err != nil { + return err +} + +// Broadcast the transaction +// (This step depends on your specific client implementation) +``` \ No newline at end of file diff --git a/client/v2/tx/common_test.go b/client/v2/tx/common_test.go new file mode 100644 index 000000000000..3b474e9fef7a --- /dev/null +++ b/client/v2/tx/common_test.go @@ -0,0 +1,116 @@ +package tx + +import ( + "context" + + "google.golang.org/grpc" + + abciv1beta1 "cosmossdk.io/api/cosmos/base/abci/v1beta1" + apitx "cosmossdk.io/api/cosmos/tx/v1beta1" + "cosmossdk.io/client/v2/autocli/keyring" + "cosmossdk.io/client/v2/internal/account" + txdecode "cosmossdk.io/x/tx/decode" + "cosmossdk.io/x/tx/signing" + + "github.com/cosmos/cosmos-sdk/codec" + addrcodec "github.com/cosmos/cosmos-sdk/codec/address" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + codec2 "github.com/cosmos/cosmos-sdk/crypto/codec" + "github.com/cosmos/cosmos-sdk/crypto/hd" + cryptoKeyring "github.com/cosmos/cosmos-sdk/crypto/keyring" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + "github.com/cosmos/cosmos-sdk/types" +) + +var ( + cdc = codec.NewProtoCodec(codectypes.NewInterfaceRegistry()) + ac = addrcodec.NewBech32Codec("cosmos") + valCodec = addrcodec.NewBech32Codec("cosmosval") + signingOptions = signing.Options{ + AddressCodec: ac, + ValidatorAddressCodec: valCodec, + } + signingContext, _ = signing.NewContext(signingOptions) + decodeOptions = txdecode.Options{SigningContext: signingContext, ProtoCodec: cdc} + decoder, _ = txdecode.NewDecoder(decodeOptions) + + k = cryptoKeyring.NewInMemory(cdc) + keybase, _ = cryptoKeyring.NewAutoCLIKeyring(k, ac) + txConf, _ = NewTxConfig(ConfigOptions{ + AddressCodec: ac, + Cdc: cdc, + ValidatorAddressCodec: valCodec, + }) +) + +func setKeyring() keyring.Keyring { + registry := codectypes.NewInterfaceRegistry() + codec2.RegisterInterfaces(registry) + cdc := codec.NewProtoCodec(registry) + k := cryptoKeyring.NewInMemory(cdc) + _, err := k.NewAccount("alice", "equip will roof matter pink blind book anxiety banner elbow sun young", "", "m/44'/118'/0'/0/0", hd.Secp256k1) + if err != nil { + panic(err) + } + keybase, err := cryptoKeyring.NewAutoCLIKeyring(k, ac) + if err != nil { + panic(err) + } + return keybase +} + +type mockAccount struct { + addr []byte +} + +func (m mockAccount) GetAddress() types.AccAddress { + return m.addr +} + +func (m mockAccount) GetPubKey() cryptotypes.PubKey { + return nil +} + +func (m mockAccount) GetAccountNumber() uint64 { + return 1 +} + +func (m mockAccount) GetSequence() uint64 { + return 0 +} + +type mockAccountRetriever struct{} + +func (m mockAccountRetriever) GetAccount(_ context.Context, address []byte) (account.Account, error) { + return mockAccount{addr: address}, nil +} + +func (m mockAccountRetriever) GetAccountWithHeight(_ context.Context, address []byte) (account.Account, int64, error) { + return mockAccount{addr: address}, 0, nil +} + +func (m mockAccountRetriever) EnsureExists(_ context.Context, _ []byte) error { + return nil +} + +func (m mockAccountRetriever) GetAccountNumberSequence(_ context.Context, _ []byte) (accNum, accSeq uint64, err error) { + return accNum, accSeq, nil +} + +type mockClientConn struct{} + +func (m mockClientConn) Invoke(_ context.Context, _ string, _, reply interface{}, _ ...grpc.CallOption) error { + simResponse := apitx.SimulateResponse{ + GasInfo: &abciv1beta1.GasInfo{ + GasWanted: 10000, + GasUsed: 7500, + }, + Result: nil, + } + *reply.(*apitx.SimulateResponse) = simResponse // nolint:govet // ignore linting error + return nil +} + +func (m mockClientConn) NewStream(_ context.Context, _ *grpc.StreamDesc, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, nil +} diff --git a/client/v2/tx/config.go b/client/v2/tx/config.go new file mode 100644 index 000000000000..a500f7c9b009 --- /dev/null +++ b/client/v2/tx/config.go @@ -0,0 +1,338 @@ +package tx + +import ( + "errors" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/anypb" + + apitxsigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + "cosmossdk.io/core/address" + txdecode "cosmossdk.io/x/tx/decode" + "cosmossdk.io/x/tx/signing" + "cosmossdk.io/x/tx/signing/aminojson" + "cosmossdk.io/x/tx/signing/direct" + "cosmossdk.io/x/tx/signing/directaux" + "cosmossdk.io/x/tx/signing/textual" + + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" +) + +var ( + _ TxConfig = txConfig{} + _ TxEncodingConfig = defaultEncodingConfig{} + _ TxSigningConfig = defaultTxSigningConfig{} + + defaultEnabledSignModes = []apitxsigning.SignMode{ + apitxsigning.SignMode_SIGN_MODE_DIRECT, + apitxsigning.SignMode_SIGN_MODE_DIRECT_AUX, + apitxsigning.SignMode_SIGN_MODE_LEGACY_AMINO_JSON, + } +) + +// TxConfig is an interface that a client can use to generate a concrete transaction type +// defined by the application. +type TxConfig interface { + TxEncodingConfig + TxSigningConfig +} + +// TxEncodingConfig defines the interface for transaction encoding and decoding. +// It provides methods for both binary and JSON encoding/decoding. +type TxEncodingConfig interface { + // TxEncoder returns an encoder for binary transaction encoding. + TxEncoder() txEncoder + // TxDecoder returns a decoder for binary transaction decoding. + TxDecoder() txDecoder + // TxJSONEncoder returns an encoder for JSON transaction encoding. + TxJSONEncoder() txEncoder + // TxJSONDecoder returns a decoder for JSON transaction decoding. + TxJSONDecoder() txDecoder + // Decoder returns the Decoder interface for decoding transaction bytes into a DecodedTx. + Decoder() Decoder +} + +// TxSigningConfig defines the interface for transaction signing configurations. +type TxSigningConfig interface { + // SignModeHandler returns a reference to the HandlerMap which manages the different signing modes. + SignModeHandler() *signing.HandlerMap + // SigningContext returns a reference to the Context which holds additional data required during signing. + SigningContext() *signing.Context + // MarshalSignatureJSON takes a slice of Signature objects and returns their JSON encoding. + MarshalSignatureJSON([]Signature) ([]byte, error) + // UnmarshalSignatureJSON takes a JSON byte slice and returns a slice of Signature objects. + UnmarshalSignatureJSON([]byte) ([]Signature, error) +} + +// ConfigOptions defines the configuration options for transaction processing. +type ConfigOptions struct { + AddressCodec address.Codec + Decoder Decoder + Cdc codec.BinaryCodec + + ValidatorAddressCodec address.Codec + FileResolver signing.ProtoFileResolver + TypeResolver signing.TypeResolver + CustomGetSigner map[protoreflect.FullName]signing.GetSignersFunc + MaxRecursionDepth int + + EnablesSignModes []apitxsigning.SignMode + CustomSignModes []signing.SignModeHandler + TextualCoinMetadataQueryFn textual.CoinMetadataQueryFn +} + +// validate checks the ConfigOptions for required fields and sets default values where necessary. +// It returns an error if any required field is missing. +func (c *ConfigOptions) validate() error { + if c.AddressCodec == nil { + return errors.New("address codec cannot be nil") + } + if c.Cdc == nil { + return errors.New("codec cannot be nil") + } + if c.ValidatorAddressCodec == nil { + return errors.New("validator address codec cannot be nil") + } + + // set default signModes if none are provided + if len(c.EnablesSignModes) == 0 { + c.EnablesSignModes = defaultEnabledSignModes + } + return nil +} + +// txConfig is a struct that embeds TxEncodingConfig and TxSigningConfig interfaces. +type txConfig struct { + TxEncodingConfig + TxSigningConfig +} + +// NewTxConfig creates a new TxConfig instance using the provided ConfigOptions. +// It validates the options, initializes the signing context, and sets up the decoder if not provided. +func NewTxConfig(options ConfigOptions) (TxConfig, error) { + err := options.validate() + if err != nil { + return nil, err + } + + signingCtx, err := newDefaultTxSigningConfig(options) + if err != nil { + return nil, err + } + + if options.Decoder == nil { + options.Decoder, err = txdecode.NewDecoder(txdecode.Options{ + SigningContext: signingCtx.SigningContext(), + ProtoCodec: options.Cdc, + }) + if err != nil { + return nil, err + } + } + + return &txConfig{ + TxEncodingConfig: defaultEncodingConfig{ + cdc: options.Cdc, + decoder: options.Decoder, + }, + TxSigningConfig: signingCtx, + }, nil +} + +// defaultEncodingConfig is an empty struct that implements the TxEncodingConfig interface. +type defaultEncodingConfig struct { + cdc codec.BinaryCodec + decoder Decoder +} + +// TxEncoder returns the default transaction encoder. +func (t defaultEncodingConfig) TxEncoder() txEncoder { + return encodeTx +} + +// TxDecoder returns the default transaction decoder. +func (t defaultEncodingConfig) TxDecoder() txDecoder { + return decodeTx(t.cdc, t.decoder) +} + +// TxJSONEncoder returns the default JSON transaction encoder. +func (t defaultEncodingConfig) TxJSONEncoder() txEncoder { + return encodeJsonTx +} + +// TxJSONDecoder returns the default JSON transaction decoder. +func (t defaultEncodingConfig) TxJSONDecoder() txDecoder { + return decodeJsonTx(t.cdc, t.decoder) +} + +// Decoder returns the Decoder instance associated with this encoding configuration. +func (t defaultEncodingConfig) Decoder() Decoder { + return t.decoder +} + +// defaultTxSigningConfig is a struct that holds the signing context and handler map. +type defaultTxSigningConfig struct { + signingCtx *signing.Context + handlerMap *signing.HandlerMap + cdc codec.BinaryCodec +} + +// newDefaultTxSigningConfig creates a new defaultTxSigningConfig instance using the provided ConfigOptions. +// It initializes the signing context and handler map. +func newDefaultTxSigningConfig(opts ConfigOptions) (*defaultTxSigningConfig, error) { + signingCtx, err := newSigningContext(opts) + if err != nil { + return nil, err + } + + handlerMap, err := newHandlerMap(opts, signingCtx) + if err != nil { + return nil, err + } + + return &defaultTxSigningConfig{ + signingCtx: signingCtx, + handlerMap: handlerMap, + cdc: opts.Cdc, + }, nil +} + +// SignModeHandler returns the handler map that manages the different signing modes. +func (t defaultTxSigningConfig) SignModeHandler() *signing.HandlerMap { + return t.handlerMap +} + +// SigningContext returns the signing context that holds additional data required during signing. +func (t defaultTxSigningConfig) SigningContext() *signing.Context { + return t.signingCtx +} + +// MarshalSignatureJSON takes a slice of Signature objects and returns their JSON encoding. +// This method is not yet implemented and will panic if called. +func (t defaultTxSigningConfig) MarshalSignatureJSON(signatures []Signature) ([]byte, error) { + descriptor := make([]*apitxsigning.SignatureDescriptor, len(signatures)) + + for i, sig := range signatures { + descData, err := signatureDataToProto(sig.Data) + if err != nil { + return nil, err + } + + anyPk, err := codectypes.NewAnyWithValue(sig.PubKey) + if err != nil { + return nil, err + } + + descriptor[i] = &apitxsigning.SignatureDescriptor{ + PublicKey: &anypb.Any{ + TypeUrl: codectypes.MsgTypeURL(sig.PubKey), + Value: anyPk.Value, + }, + Data: descData, + Sequence: sig.Sequence, + } + } + + return jsonMarshalOptions.Marshal(&apitxsigning.SignatureDescriptors{Signatures: descriptor}) +} + +// UnmarshalSignatureJSON takes a JSON byte slice and returns a slice of Signature objects. +// This method is not yet implemented and will panic if called. +func (t defaultTxSigningConfig) UnmarshalSignatureJSON(bz []byte) ([]Signature, error) { + var descriptor apitxsigning.SignatureDescriptors + + err := protojson.UnmarshalOptions{}.Unmarshal(bz, &descriptor) + if err != nil { + return nil, err + } + + sigs := make([]Signature, len(descriptor.Signatures)) + for i, desc := range descriptor.Signatures { + var pubkey cryptotypes.PubKey + + anyPk := &codectypes.Any{ + TypeUrl: desc.PublicKey.TypeUrl, + Value: desc.PublicKey.Value, + } + + err = t.cdc.UnpackAny(anyPk, &pubkey) + if err != nil { + return nil, err + } + + data, err := SignatureDataFromProto(desc.Data) + if err != nil { + return nil, err + } + + sigs[i] = Signature{ + PubKey: pubkey, + Data: data, + Sequence: desc.Sequence, + } + } + + return sigs, nil +} + +// newSigningContext creates a new signing context using the provided ConfigOptions. +// Returns a signing.Context instance or an error if initialization fails. +func newSigningContext(opts ConfigOptions) (*signing.Context, error) { + return signing.NewContext(signing.Options{ + FileResolver: opts.FileResolver, + TypeResolver: opts.TypeResolver, + AddressCodec: opts.AddressCodec, + ValidatorAddressCodec: opts.ValidatorAddressCodec, + CustomGetSigners: opts.CustomGetSigner, + MaxRecursionDepth: opts.MaxRecursionDepth, + }) +} + +// newHandlerMap constructs a new HandlerMap based on the provided ConfigOptions and signing context. +// It initializes handlers for each enabled and custom sign mode specified in the options. +func newHandlerMap(opts ConfigOptions, signingCtx *signing.Context) (*signing.HandlerMap, error) { + lenSignModes := len(opts.EnablesSignModes) + handlers := make([]signing.SignModeHandler, lenSignModes+len(opts.CustomSignModes)) + + for i, m := range opts.EnablesSignModes { + var err error + switch m { + case apitxsigning.SignMode_SIGN_MODE_DIRECT: + handlers[i] = &direct.SignModeHandler{} + case apitxsigning.SignMode_SIGN_MODE_TEXTUAL: + if opts.TextualCoinMetadataQueryFn == nil { + return nil, errors.New("cannot enable SIGN_MODE_TEXTUAL without a TextualCoinMetadataQueryFn") + } + handlers[i], err = textual.NewSignModeHandler(textual.SignModeOptions{ + CoinMetadataQuerier: opts.TextualCoinMetadataQueryFn, + FileResolver: signingCtx.FileResolver(), + TypeResolver: signingCtx.TypeResolver(), + }) + if err != nil { + return nil, err + } + case apitxsigning.SignMode_SIGN_MODE_DIRECT_AUX: + handlers[i], err = directaux.NewSignModeHandler(directaux.SignModeHandlerOptions{ + TypeResolver: signingCtx.TypeResolver(), + SignersContext: signingCtx, + }) + if err != nil { + return nil, err + } + case apitxsigning.SignMode_SIGN_MODE_LEGACY_AMINO_JSON: + handlers[i] = aminojson.NewSignModeHandler(aminojson.SignModeHandlerOptions{ + FileResolver: signingCtx.FileResolver(), + TypeResolver: opts.TypeResolver, + }) + } + } + for i, m := range opts.CustomSignModes { + handlers[i+lenSignModes] = m + } + + handler := signing.NewHandlerMap(handlers...) + return handler, nil +} diff --git a/client/v2/tx/config_test.go b/client/v2/tx/config_test.go new file mode 100644 index 000000000000..7d1f223d1214 --- /dev/null +++ b/client/v2/tx/config_test.go @@ -0,0 +1,293 @@ +package tx + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + apicrypto "cosmossdk.io/api/cosmos/crypto/multisig/v1beta1" + _ "cosmossdk.io/api/cosmos/crypto/secp256k1" + apitxsigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + "cosmossdk.io/x/tx/signing" + + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/codec/address" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + codec2 "github.com/cosmos/cosmos-sdk/crypto/codec" + kmultisig "github.com/cosmos/cosmos-sdk/crypto/keys/multisig" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" +) + +type mockModeHandler struct{} + +func (t mockModeHandler) Mode() apitxsigning.SignMode { + return apitxsigning.SignMode_SIGN_MODE_DIRECT +} + +func (t mockModeHandler) GetSignBytes(_ context.Context, _ signing.SignerData, _ signing.TxData) ([]byte, error) { + return []byte{}, nil +} + +func TestConfigOptions_validate(t *testing.T) { + tests := []struct { + name string + opts ConfigOptions + wantErr bool + }{ + { + name: "valid options", + opts: ConfigOptions{ + AddressCodec: address.NewBech32Codec("cosmos"), + Decoder: decoder, + Cdc: cdc, + ValidatorAddressCodec: address.NewBech32Codec("cosmosvaloper"), + }, + }, + { + name: "missing address codec", + opts: ConfigOptions{ + Decoder: decoder, + Cdc: cdc, + ValidatorAddressCodec: address.NewBech32Codec("cosmosvaloper"), + }, + wantErr: true, + }, + { + name: "missing decoder", + opts: ConfigOptions{ + AddressCodec: address.NewBech32Codec("cosmos"), + Cdc: cdc, + ValidatorAddressCodec: address.NewBech32Codec("cosmosvaloper"), + }, + }, + { + name: "missing codec", + opts: ConfigOptions{ + AddressCodec: address.NewBech32Codec("cosmos"), + Decoder: decoder, + ValidatorAddressCodec: address.NewBech32Codec("cosmosvaloper"), + }, + wantErr: true, + }, + { + name: "missing validator address codec", + opts: ConfigOptions{ + AddressCodec: address.NewBech32Codec("cosmos"), + Decoder: decoder, + Cdc: cdc, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.opts.validate(); (err != nil) != tt.wantErr { + t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_newHandlerMap(t *testing.T) { + tests := []struct { + name string + opts ConfigOptions + }{ + { + name: "handler map with default sign modes", + opts: ConfigOptions{ + AddressCodec: address.NewBech32Codec("cosmos"), + Decoder: decoder, + Cdc: cdc, + ValidatorAddressCodec: address.NewBech32Codec("cosmosvaloper"), + }, + }, + { + name: "handler map with just one sign mode", + opts: ConfigOptions{ + AddressCodec: address.NewBech32Codec("cosmos"), + Decoder: decoder, + Cdc: cdc, + ValidatorAddressCodec: address.NewBech32Codec("cosmosvaloper"), + EnablesSignModes: []apitxsigning.SignMode{apitxsigning.SignMode_SIGN_MODE_DIRECT}, + }, + }, + { + name: "handler map with custom sign modes", + opts: ConfigOptions{ + AddressCodec: address.NewBech32Codec("cosmos"), + Decoder: decoder, + Cdc: cdc, + ValidatorAddressCodec: address.NewBech32Codec("cosmosvaloper"), + CustomSignModes: []signing.SignModeHandler{mockModeHandler{}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.opts.validate() + require.NoError(t, err) + + signingCtx, err := newSigningContext(tt.opts) + require.NoError(t, err) + + handlerMap, err := newHandlerMap(tt.opts, signingCtx) + require.NoError(t, err) + require.NotNil(t, handlerMap) + require.Equal(t, len(handlerMap.SupportedModes()), len(tt.opts.EnablesSignModes)+len(tt.opts.CustomSignModes)) + }) + } +} + +func TestNewTxConfig(t *testing.T) { + tests := []struct { + name string + options ConfigOptions + wantErr bool + }{ + { + name: "valid options", + options: ConfigOptions{ + AddressCodec: ac, + Cdc: cdc, + ValidatorAddressCodec: valCodec, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewTxConfig(tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("NewTxConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.NotNil(t, got) + }) + } +} + +func Test_defaultTxSigningConfig_MarshalSignatureJSON(t *testing.T) { + tests := []struct { + name string + options ConfigOptions + signatures func(t *testing.T) []Signature + }{ + { + name: "single signature", + options: ConfigOptions{ + AddressCodec: ac, + Cdc: cdc, + ValidatorAddressCodec: valCodec, + }, + signatures: func(t *testing.T) []Signature { + t.Helper() + + k := setKeyring() + pk, err := k.GetPubKey("alice") + require.NoError(t, err) + signature, err := k.Sign("alice", make([]byte, 10), apitxsigning.SignMode_SIGN_MODE_DIRECT) + require.NoError(t, err) + return []Signature{ + { + PubKey: pk, + Data: &SingleSignatureData{ + SignMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + Signature: signature, + }, + }, + } + }, + }, + { + name: "multisig signatures", + options: ConfigOptions{ + AddressCodec: ac, + Cdc: cdc, + ValidatorAddressCodec: valCodec, + }, + signatures: func(t *testing.T) []Signature { + t.Helper() + + n := 2 + pubKeys := make([]cryptotypes.PubKey, n) + sigs := make([]SignatureData, n) + for i := 0; i < n; i++ { + sk := secp256k1.GenPrivKey() + pubKeys[i] = sk.PubKey() + msg, err := sk.Sign(make([]byte, 10)) + require.NoError(t, err) + sigs[i] = &SingleSignatureData{ + SignMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + Signature: msg, + } + } + bitArray := cryptotypes.NewCompactBitArray(n) + mKey := kmultisig.NewLegacyAminoPubKey(n, pubKeys) + return []Signature{ + { + PubKey: mKey, + Data: &MultiSignatureData{ + BitArray: &apicrypto.CompactBitArray{ + ExtraBitsStored: bitArray.ExtraBitsStored, + Elems: bitArray.Elems, + }, + Signatures: sigs, + }, + }, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := NewTxConfig(tt.options) + require.NoError(t, err) + + got, err := config.MarshalSignatureJSON(tt.signatures(t)) + require.NoError(t, err) + require.NotNil(t, got) + }) + } +} + +func Test_defaultTxSigningConfig_UnmarshalSignatureJSON(t *testing.T) { + registry := codectypes.NewInterfaceRegistry() + codec2.RegisterInterfaces(registry) + cdc := codec.NewProtoCodec(registry) + tests := []struct { + name string + options ConfigOptions + bz []byte + }{ + { + name: "single signature", + options: ConfigOptions{ + AddressCodec: ac, + Cdc: cdc, + ValidatorAddressCodec: valCodec, + }, + bz: []byte(`{"signatures":[{"public_key":{"@type":"/cosmos.crypto.secp256k1.PubKey", "key":"A0/vnNfExjWI07A/61KBudIyy6NNbz1xruWSEf+/4f6H"}, "data":{"single":{"mode":"SIGN_MODE_DIRECT", "signature":"usUTJwdc4PWPuox0Y0G/RuHoxyj+QpUcBGvXyNdDX1FOdoVj0tg4TGKT2NnM3QP6wCNbubjHuMOhTtqfW8SkYg=="}}}]}`), + }, + { + name: "multisig signatures", + options: ConfigOptions{ + AddressCodec: ac, + Cdc: cdc, + ValidatorAddressCodec: valCodec, + }, + bz: []byte(`{"signatures":[{"public_key":{"@type":"/cosmos.crypto.multisig.LegacyAminoPubKey","threshold":2,"public_keys":[{"@type":"/cosmos.crypto.secp256k1.PubKey","key":"A4Bs9huvS/COpZNhVhTnhgc8YR6VrSQ8hLQIHgnA+m3w"},{"@type":"/cosmos.crypto.secp256k1.PubKey","key":"AuNz2lFkLn3sKNjC5r4OWhgkWg5DZpGUiR9OdpzXspnp"}]},"data":{"multi":{"bitarray":{"extra_bits_stored":2,"elems":"AA=="},"signatures":[{"single":{"mode":"SIGN_MODE_DIRECT","signature":"vng4IlPzLH3fDFpikM5y1SfXFGny4BcLGwIFU0Ty4yoWjIxjTS4m6fgDB61sxEkV5DK/CD7gUwenGuEpzJ2IGw=="}},{"single":{"mode":"SIGN_MODE_DIRECT","signature":"2dsGmr13bq/mPxbk9AgqcFpuvk4beszWu6uxkx+EhTMdVGp4J8FtjZc8xs/Pp3oTWY4ScAORYQHxwqN4qwMXGg=="}}]}}}]}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := NewTxConfig(tt.options) + require.NoError(t, err) + + got, err := config.UnmarshalSignatureJSON(tt.bz) + require.NoError(t, err) + require.NotNil(t, got) + }) + } +} diff --git a/client/v2/tx/encoder.go b/client/v2/tx/encoder.go new file mode 100644 index 000000000000..2094efe6d3d5 --- /dev/null +++ b/client/v2/tx/encoder.go @@ -0,0 +1,119 @@ +package tx + +import ( + "fmt" + + "google.golang.org/protobuf/encoding/protojson" + protov2 "google.golang.org/protobuf/proto" + + txv1beta1 "cosmossdk.io/api/cosmos/tx/v1beta1" + txdecode "cosmossdk.io/x/tx/decode" + + "github.com/cosmos/cosmos-sdk/codec" +) + +var ( + // marshalOption configures protobuf marshaling to be deterministic. + marshalOption = protov2.MarshalOptions{Deterministic: true} + + // jsonMarshalOptions configures JSON marshaling for protobuf messages. + jsonMarshalOptions = protojson.MarshalOptions{ + Indent: "", + UseProtoNames: true, + UseEnumNumbers: false, + } +) + +// Decoder defines the interface for decoding transaction bytes into a DecodedTx. +type Decoder interface { + Decode(txBytes []byte) (*txdecode.DecodedTx, error) +} + +// txDecoder is a function type that unmarshals transaction bytes into an API Tx type. +type txDecoder func(txBytes []byte) (Tx, error) + +// txEncoder is a function type that marshals a transaction into bytes. +type txEncoder func(tx Tx) ([]byte, error) + +// decodeTx decodes transaction bytes into an apitx.Tx structure. +func decodeTx(cdc codec.BinaryCodec, decoder Decoder) txDecoder { + return func(txBytes []byte) (Tx, error) { + tx := new(txv1beta1.Tx) + err := protov2.Unmarshal(txBytes, tx) + if err != nil { + return nil, err + } + + pTxBytes, err := protoTxBytes(tx) + if err != nil { + return nil, err + } + + decodedTx, err := decoder.Decode(pTxBytes) + if err != nil { + return nil, err + } + return newWrapperTx(cdc, decodedTx), nil + } +} + +// encodeTx encodes an apitx.Tx into bytes using protobuf marshaling options. +func encodeTx(tx Tx) ([]byte, error) { + wTx, ok := tx.(*wrappedTx) + if !ok { + return nil, fmt.Errorf("unexpected tx type: %T", tx) + } + return marshalOption.Marshal(wTx.Tx) +} + +// decodeJsonTx decodes transaction bytes into an apitx.Tx structure using JSON format. +func decodeJsonTx(cdc codec.BinaryCodec, decoder Decoder) txDecoder { + return func(txBytes []byte) (Tx, error) { + jsonTx := new(txv1beta1.Tx) + err := protojson.UnmarshalOptions{ + AllowPartial: false, + DiscardUnknown: false, + }.Unmarshal(txBytes, jsonTx) + if err != nil { + return nil, err + } + + pTxBytes, err := protoTxBytes(jsonTx) + if err != nil { + return nil, err + } + + decodedTx, err := decoder.Decode(pTxBytes) + if err != nil { + return nil, err + } + return newWrapperTx(cdc, decodedTx), nil + } +} + +// encodeJsonTx encodes an apitx.Tx into bytes using JSON marshaling options. +func encodeJsonTx(tx Tx) ([]byte, error) { + wTx, ok := tx.(*wrappedTx) + if !ok { + return nil, fmt.Errorf("unexpected tx type: %T", tx) + } + return jsonMarshalOptions.Marshal(wTx.Tx) +} + +func protoTxBytes(tx *txv1beta1.Tx) ([]byte, error) { + bodyBytes, err := marshalOption.Marshal(tx.Body) + if err != nil { + return nil, err + } + + authInfoBytes, err := marshalOption.Marshal(tx.AuthInfo) + if err != nil { + return nil, err + } + + return marshalOption.Marshal(&txv1beta1.TxRaw{ + BodyBytes: bodyBytes, + AuthInfoBytes: authInfoBytes, + Signatures: tx.Signatures, + }) +} diff --git a/client/v2/tx/encoder_test.go b/client/v2/tx/encoder_test.go new file mode 100644 index 000000000000..9dec56762318 --- /dev/null +++ b/client/v2/tx/encoder_test.go @@ -0,0 +1,107 @@ +package tx + +import ( + "testing" + + "github.com/stretchr/testify/require" + + base "cosmossdk.io/api/cosmos/base/v1beta1" + countertypes "cosmossdk.io/api/cosmos/counter/v1" + apisigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + "cosmossdk.io/core/transaction" + + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" +) + +func getWrappedTx(t *testing.T) *wrappedTx { + t.Helper() + + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + + pk := secp256k1.GenPrivKey().PubKey() + addr, _ := ac.BytesToString(pk.Address()) + + f.tx.msgs = []transaction.Msg{&countertypes.MsgIncreaseCounter{ + Signer: addr, + Count: 0, + }} + require.NoError(t, err) + + err = f.setFeePayer(addr) + require.NoError(t, err) + + f.tx.fees = []*base.Coin{{ + Denom: "cosmos", + Amount: "1000", + }} + + err = f.setSignatures([]Signature{{ + PubKey: pk, + Data: &SingleSignatureData{ + SignMode: apisigning.SignMode_SIGN_MODE_DIRECT, + Signature: nil, + }, + Sequence: 0, + }}...) + require.NoError(t, err) + wTx, err := f.getTx() + require.NoError(t, err) + return wTx +} + +func Test_txEncoder_txDecoder(t *testing.T) { + wTx := getWrappedTx(t) + + encodedTx, err := encodeTx(wTx) + require.NoError(t, err) + require.NotNil(t, encodedTx) + + isDeterministic, err := encodeTx(wTx) + require.NoError(t, err) + require.NotNil(t, encodedTx) + require.Equal(t, encodedTx, isDeterministic) + + f := decodeTx(cdc, decoder) + decodedTx, err := f(encodedTx) + require.NoError(t, err) + require.NotNil(t, decodedTx) + + dTx, ok := decodedTx.(*wrappedTx) + require.True(t, ok) + require.Equal(t, wTx.TxRaw, dTx.TxRaw) + require.Equal(t, wTx.Tx.AuthInfo.String(), dTx.Tx.AuthInfo.String()) + require.Equal(t, wTx.Tx.Body.String(), dTx.Tx.Body.String()) + require.Equal(t, wTx.Tx.Signatures, dTx.Tx.Signatures) +} + +func Test_txJsonEncoder_txJsonDecoder(t *testing.T) { + tests := []struct { + name string + }{ + { + name: "json encode and decode tx", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wTx := getWrappedTx(t) + + encodedTx, err := encodeJsonTx(wTx) + require.NoError(t, err) + require.NotNil(t, encodedTx) + + f := decodeJsonTx(cdc, decoder) + decodedTx, err := f(encodedTx) + require.NoError(t, err) + require.NotNil(t, decodedTx) + + dTx, ok := decodedTx.(*wrappedTx) + require.True(t, ok) + require.Equal(t, wTx.TxRaw, dTx.TxRaw) + require.Equal(t, wTx.Tx.AuthInfo.String(), dTx.Tx.AuthInfo.String()) + require.Equal(t, wTx.Tx.Body.String(), dTx.Tx.Body.String()) + require.Equal(t, wTx.Tx.Signatures, dTx.Tx.Signatures) + }) + } +} diff --git a/client/v2/tx/factory.go b/client/v2/tx/factory.go new file mode 100644 index 000000000000..9dd0eae21a34 --- /dev/null +++ b/client/v2/tx/factory.go @@ -0,0 +1,761 @@ +package tx + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/cosmos/go-bip39" + gogogrpc "github.com/cosmos/gogoproto/grpc" + "github.com/spf13/pflag" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/timestamppb" + + base "cosmossdk.io/api/cosmos/base/v1beta1" + apicrypto "cosmossdk.io/api/cosmos/crypto/multisig/v1beta1" + apitxsigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + apitx "cosmossdk.io/api/cosmos/tx/v1beta1" + "cosmossdk.io/client/v2/autocli/keyring" + "cosmossdk.io/client/v2/internal/account" + "cosmossdk.io/client/v2/internal/coins" + "cosmossdk.io/core/address" + "cosmossdk.io/core/transaction" + "cosmossdk.io/math" + "cosmossdk.io/x/tx/signing" + + flags2 "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/multisig" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" +) + +// Factory defines a client transaction factory that facilitates generating and +// signing an application-specific transaction. +type Factory struct { + keybase keyring.Keyring + cdc codec.BinaryCodec + accountRetriever account.AccountRetriever + ac address.Codec + conn gogogrpc.ClientConn + txConfig TxConfig + txParams TxParameters + + tx txState +} + +func NewFactoryFromFlagSet(flags *pflag.FlagSet, keybase keyring.Keyring, cdc codec.BinaryCodec, accRetriever account.AccountRetriever, + txConfig TxConfig, ac address.Codec, conn gogogrpc.ClientConn, +) (Factory, error) { + offline, _ := flags.GetBool(flags2.FlagOffline) + if err := validateFlagSet(flags, offline); err != nil { + return Factory{}, err + } + + params, err := txParamsFromFlagSet(flags, keybase, ac) + if err != nil { + return Factory{}, err + } + + params, err = prepareTxParams(params, accRetriever, offline) + if err != nil { + return Factory{}, err + } + + return NewFactory(keybase, cdc, accRetriever, txConfig, ac, conn, params) +} + +// NewFactory returns a new instance of Factory. +func NewFactory(keybase keyring.Keyring, cdc codec.BinaryCodec, accRetriever account.AccountRetriever, + txConfig TxConfig, ac address.Codec, conn gogogrpc.ClientConn, parameters TxParameters, +) (Factory, error) { + return Factory{ + keybase: keybase, + cdc: cdc, + accountRetriever: accRetriever, + ac: ac, + conn: conn, + txConfig: txConfig, + txParams: parameters, + + tx: txState{}, + }, nil +} + +// validateFlagSet checks the provided flags for consistency and requirements based on the operation mode. +func validateFlagSet(flags *pflag.FlagSet, offline bool) error { + if offline { + if !flags.Changed(flags2.FlagAccountNumber) || !flags.Changed(flags2.FlagSequence) { + return errors.New("account-number and sequence must be set in offline mode") + } + + gas, _ := flags.GetString(flags2.FlagGas) + gasSetting, _ := flags2.ParseGasSetting(gas) + if gasSetting.Simulate { + return errors.New("simulate and offline flags cannot be set at the same time") + } + } + + generateOnly, _ := flags.GetBool(flags2.FlagGenerateOnly) + chainID, _ := flags.GetString(flags2.FlagChainID) + if offline && generateOnly && chainID != "" { + return errors.New("chain ID cannot be used when offline and generate-only flags are set") + } + if chainID == "" { + return errors.New("chain ID required but not specified") + } + + dryRun, _ := flags.GetBool(flags2.FlagDryRun) + if offline && dryRun { + return errors.New("dry-run: cannot use offline mode") + } + + return nil +} + +// prepareTxParams ensures the account defined by ctx.GetFromAddress() exists and +// if the account number and/or the account sequence number are zero (not set), +// they will be queried for and set on the provided Factory. +func prepareTxParams(parameters TxParameters, accRetriever account.AccountRetriever, offline bool) (TxParameters, error) { + if offline { + return parameters, nil + } + + if len(parameters.address) == 0 { + return parameters, errors.New("missing 'from address' field") + } + + if parameters.accountNumber == 0 || parameters.sequence == 0 { + num, seq, err := accRetriever.GetAccountNumberSequence(context.Background(), parameters.address) + if err != nil { + return parameters, err + } + + if parameters.accountNumber == 0 { + parameters.accountNumber = num + } + + if parameters.sequence == 0 { + parameters.sequence = seq + } + } + + return parameters, nil +} + +// BuildUnsignedTx builds a transaction to be signed given a set of messages. +// Once created, the fee, memo, and messages are set. +func (f *Factory) BuildUnsignedTx(msgs ...transaction.Msg) error { + fees := f.txParams.fees + + isGasPriceZero, err := coins.IsZero(f.txParams.gasPrices) + if err != nil { + return err + } + if !isGasPriceZero { + areFeesZero, err := coins.IsZero(fees) + if err != nil { + return err + } + if !areFeesZero { + return errors.New("cannot provide both fees and gas prices") + } + + // f.gas is an uint64 and we should convert to LegacyDec + // without the risk of under/overflow via uint64->int64. + glDec := math.LegacyNewDecFromBigInt(new(big.Int).SetUint64(f.txParams.gas)) + + // Derive the fees based on the provided gas prices, where + // fee = ceil(gasPrice * gasLimit). + fees = make([]*base.Coin, len(f.txParams.gasPrices)) + + for i, gp := range f.txParams.gasPrices { + fee, err := math.LegacyNewDecFromStr(gp.Amount) + if err != nil { + return err + } + fee = fee.Mul(glDec) + fees[i] = &base.Coin{Denom: gp.Denom, Amount: fee.Ceil().RoundInt().String()} + } + } + + if err := validateMemo(f.txParams.memo); err != nil { + return err + } + + f.tx.msgs = msgs + f.tx.memo = f.txParams.memo + f.tx.fees = fees + f.tx.gasLimit = f.txParams.gas + f.tx.unordered = f.txParams.unordered + f.tx.timeoutTimestamp = f.txParams.timeoutTimestamp + + err = f.setFeeGranter(f.txParams.feeGranter) + if err != nil { + return err + } + err = f.setFeePayer(f.txParams.feePayer) + if err != nil { + return err + } + + return nil +} + +func (f *Factory) BuildsSignedTx(ctx context.Context, msgs ...transaction.Msg) (Tx, error) { + err := f.BuildUnsignedTx(msgs...) + if err != nil { + return nil, err + } + + return f.sign(ctx, true) +} + +// calculateGas calculates the gas required for the given messages. +func (f *Factory) calculateGas(msgs ...transaction.Msg) error { + _, adjusted, err := f.Simulate(msgs...) + if err != nil { + return err + } + + f.WithGas(adjusted) + + return nil +} + +// Simulate simulates the execution of a transaction and returns the +// simulation response obtained by the query and the adjusted gas amount. +func (f *Factory) Simulate(msgs ...transaction.Msg) (*apitx.SimulateResponse, uint64, error) { + txBytes, err := f.BuildSimTx(msgs...) + if err != nil { + return nil, 0, err + } + + txSvcClient := apitx.NewServiceClient(f.conn) + simRes, err := txSvcClient.Simulate(context.Background(), &apitx.SimulateRequest{ + TxBytes: txBytes, + }) + if err != nil { + return nil, 0, err + } + + return simRes, uint64(f.gasAdjustment() * float64(simRes.GasInfo.GasUsed)), nil +} + +// UnsignedTxString will generate an unsigned transaction and print it to the writer +// specified by ctx.Output. If simulation was requested, the gas will be +// simulated and also printed to the same writer before the transaction is +// printed. +func (f *Factory) UnsignedTxString(msgs ...transaction.Msg) (string, error) { + if f.simulateAndExecute() { + err := f.calculateGas(msgs...) + if err != nil { + return "", err + } + } + + err := f.BuildUnsignedTx(msgs...) + if err != nil { + return "", err + } + + encoder := f.txConfig.TxJSONEncoder() + if encoder == nil { + return "", errors.New("cannot print unsigned tx: tx json encoder is nil") + } + + tx, err := f.getTx() + if err != nil { + return "", err + } + + json, err := encoder(tx) + if err != nil { + return "", err + } + + return fmt.Sprintf("%s\n", json), nil +} + +// BuildSimTx creates an unsigned tx with an empty single signature and returns +// the encoded transaction or an error if the unsigned transaction cannot be +// built. +func (f *Factory) BuildSimTx(msgs ...transaction.Msg) ([]byte, error) { + err := f.BuildUnsignedTx(msgs...) + if err != nil { + return nil, err + } + + pk, err := f.getSimPK() + if err != nil { + return nil, err + } + + // Create an empty signature literal as the ante handler will populate with a + // sentinel pubkey. + sig := Signature{ + PubKey: pk, + Data: f.getSimSignatureData(pk), + Sequence: f.sequence(), + } + if err := f.setSignatures(sig); err != nil { + return nil, err + } + + encoder := f.txConfig.TxEncoder() + if encoder == nil { + return nil, fmt.Errorf("cannot simulate tx: tx encoder is nil") + } + + tx, err := f.getTx() + if err != nil { + return nil, err + } + return encoder(tx) +} + +// sign signs a given tx with a named key. The bytes signed over are canonical. +// The resulting signature will be added to the transaction builder overwriting the previous +// ones if overwrite=true (otherwise, the signature will be appended). +// Signing a transaction with multiple signers in the DIRECT mode is not supported and will +// return an error. +func (f *Factory) sign(ctx context.Context, overwriteSig bool) (Tx, error) { + if f.keybase == nil { + return nil, errors.New("keybase must be set prior to signing a transaction") + } + + var err error + if f.txParams.signMode == apitxsigning.SignMode_SIGN_MODE_UNSPECIFIED { + f.txParams.signMode = f.txConfig.SignModeHandler().DefaultMode() + } + + pubKey, err := f.keybase.GetPubKey(f.txParams.fromName) + if err != nil { + return nil, err + } + + addr, err := f.ac.BytesToString(pubKey.Address()) + if err != nil { + return nil, err + } + + signerData := signing.SignerData{ + ChainID: f.txParams.chainID, + AccountNumber: f.txParams.accountNumber, + Sequence: f.txParams.sequence, + PubKey: &anypb.Any{ + TypeUrl: codectypes.MsgTypeURL(pubKey), + Value: pubKey.Bytes(), + }, + Address: addr, + } + + // For SIGN_MODE_DIRECT, we need to set the SignerInfos before generating + // the sign bytes. This is done by calling setSignatures with a nil + // signature, which in turn calls setSignerInfos internally. + // + // For SIGN_MODE_LEGACY_AMINO, this step is not strictly necessary, + // but we include it for consistency across all sign modes. + // It does not affect the generated sign bytes for LEGACY_AMINO. + // + // By setting the signatures here, we ensure that the correct SignerInfos + // are in place for all subsequent operations, regardless of the sign mode. + sigData := SingleSignatureData{ + SignMode: f.txParams.signMode, + Signature: nil, + } + sig := Signature{ + PubKey: pubKey, + Data: &sigData, + Sequence: f.txParams.sequence, + } + + var prevSignatures []Signature + if !overwriteSig { + tx, err := f.getTx() + if err != nil { + return nil, err + } + + prevSignatures, err = tx.GetSignatures() + if err != nil { + return nil, err + } + } + // Overwrite or append signer infos. + var sigs []Signature + if overwriteSig { + sigs = []Signature{sig} + } else { + sigs = append(sigs, prevSignatures...) + sigs = append(sigs, sig) + } + if err := f.setSignatures(sigs...); err != nil { + return nil, err + } + + tx, err := f.getTx() + if err != nil { + return nil, err + } + + if err := checkMultipleSigners(tx); err != nil { + return nil, err + } + + bytesToSign, err := f.getSignBytesAdapter(ctx, signerData) + if err != nil { + return nil, err + } + + // Sign those bytes + sigBytes, err := f.keybase.Sign(f.txParams.fromName, bytesToSign, f.txParams.signMode) + if err != nil { + return nil, err + } + + // Construct the SignatureV2 struct + sigData = SingleSignatureData{ + SignMode: f.signMode(), + Signature: sigBytes, + } + sig = Signature{ + PubKey: pubKey, + Data: &sigData, + Sequence: f.txParams.sequence, + } + + if overwriteSig { + err = f.setSignatures(sig) + } else { + prevSignatures = append(prevSignatures, sig) + err = f.setSignatures(prevSignatures...) + } + + if err != nil { + return nil, fmt.Errorf("unable to set signatures on payload: %w", err) + } + + return f.getTx() +} + +// getSignBytesAdapter returns the sign bytes for a given transaction and sign mode. +func (f *Factory) getSignBytesAdapter(ctx context.Context, signerData signing.SignerData) ([]byte, error) { + txData, err := f.getSigningTxData() + if err != nil { + return nil, err + } + + // Generate the bytes to be signed. + return f.txConfig.SignModeHandler().GetSignBytes(ctx, f.signMode(), signerData, *txData) +} + +// WithGas returns a copy of the Factory with an updated gas value. +func (f *Factory) WithGas(gas uint64) { + f.txParams.gas = gas +} + +// WithSequence returns a copy of the Factory with an updated sequence number. +func (f *Factory) WithSequence(sequence uint64) { + f.txParams.sequence = sequence +} + +// WithAccountNumber returns a copy of the Factory with an updated account number. +func (f *Factory) WithAccountNumber(accnum uint64) { + f.txParams.accountNumber = accnum +} + +// sequence returns the sequence number. +func (f *Factory) sequence() uint64 { return f.txParams.sequence } + +// gasAdjustment returns the gas adjustment value. +func (f *Factory) gasAdjustment() float64 { return f.txParams.gasAdjustment } + +// simulateAndExecute returns whether to simulate and execute. +func (f *Factory) simulateAndExecute() bool { return f.txParams.simulateAndExecute } + +// signMode returns the sign mode. +func (f *Factory) signMode() apitxsigning.SignMode { return f.txParams.signMode } + +// getSimPK gets the public key to use for building a simulation tx. +// Note, we should only check for keys in the keybase if we are in simulate and execute mode, +// e.g. when using --gas=auto. +// When using --dry-run, we are is simulation mode only and should not check the keybase. +// Ref: https://github.com/cosmos/cosmos-sdk/issues/11283 +func (f *Factory) getSimPK() (cryptotypes.PubKey, error) { + var ( + err error + pk cryptotypes.PubKey = &secp256k1.PubKey{} + ) + + if f.txParams.simulateAndExecute && f.keybase != nil { + pk, err = f.keybase.GetPubKey(f.txParams.fromName) + if err != nil { + return nil, err + } + } else { + // When in dry-run mode, attempt to retrieve the account using the provided address. + // If the account retrieval fails, the default public key is used. + acc, err := f.accountRetriever.GetAccount(context.Background(), f.txParams.address) + if err != nil { + // If there is an error retrieving the account, return the default public key. + return pk, nil + } + // If the account is successfully retrieved, use its public key. + pk = acc.GetPubKey() + } + + return pk, nil +} + +// getSimSignatureData based on the pubKey type gets the correct SignatureData type +// to use for building a simulation tx. +func (f *Factory) getSimSignatureData(pk cryptotypes.PubKey) SignatureData { + multisigPubKey, ok := pk.(*multisig.LegacyAminoPubKey) + if !ok { + return &SingleSignatureData{SignMode: f.txParams.signMode} + } + + multiSignatureData := make([]SignatureData, 0, multisigPubKey.Threshold) + for i := uint32(0); i < multisigPubKey.Threshold; i++ { + multiSignatureData = append(multiSignatureData, &SingleSignatureData{ + SignMode: f.signMode(), + }) + } + + return &MultiSignatureData{ + BitArray: &apicrypto.CompactBitArray{}, + Signatures: multiSignatureData, + } +} + +func (f *Factory) getTx() (*wrappedTx, error) { + msgs, err := msgsV1toAnyV2(f.tx.msgs) + if err != nil { + return nil, err + } + + body := &apitx.TxBody{ + Messages: msgs, + Memo: f.tx.memo, + TimeoutHeight: f.tx.timeoutHeight, + TimeoutTimestamp: timestamppb.New(f.tx.timeoutTimestamp), + Unordered: f.tx.unordered, + ExtensionOptions: f.tx.extensionOptions, + NonCriticalExtensionOptions: f.tx.nonCriticalExtensionOptions, + } + + fee, err := f.getFee() + if err != nil { + return nil, err + } + + authInfo := &apitx.AuthInfo{ + SignerInfos: f.tx.signerInfos, + Fee: fee, + } + + bodyBytes, err := marshalOption.Marshal(body) + if err != nil { + return nil, err + } + + authInfoBytes, err := marshalOption.Marshal(authInfo) + if err != nil { + return nil, err + } + + txRawBytes, err := marshalOption.Marshal(&apitx.TxRaw{ + BodyBytes: bodyBytes, + AuthInfoBytes: authInfoBytes, + Signatures: f.tx.signatures, + }) + if err != nil { + return nil, err + } + + decodedTx, err := f.txConfig.Decoder().Decode(txRawBytes) + if err != nil { + return nil, err + } + + return newWrapperTx(f.cdc, decodedTx), nil +} + +// getSigningTxData returns a TxData with the current txState info. +func (f *Factory) getSigningTxData() (*signing.TxData, error) { + tx, err := f.getTx() + if err != nil { + return nil, err + } + + return &signing.TxData{ + Body: tx.Tx.Body, + AuthInfo: tx.Tx.AuthInfo, + BodyBytes: tx.TxRaw.BodyBytes, + AuthInfoBytes: tx.TxRaw.AuthInfoBytes, + BodyHasUnknownNonCriticals: tx.TxBodyHasUnknownNonCriticals, + }, nil +} + +// setSignatures sets the signatures for the transaction builder. +// It takes a variable number of Signature arguments and processes each one to extract the mode information and raw signature. +// It also converts the public key to the appropriate format and sets the signer information. +func (f *Factory) setSignatures(signatures ...Signature) error { + n := len(signatures) + signerInfos := make([]*apitx.SignerInfo, n) + rawSignatures := make([][]byte, n) + + for i, sig := range signatures { + var ( + modeInfo *apitx.ModeInfo + pubKey *codectypes.Any + err error + anyPk *anypb.Any + ) + + modeInfo, rawSignatures[i] = signatureDataToModeInfoAndSig(sig.Data) + if sig.PubKey != nil { + pubKey, err = codectypes.NewAnyWithValue(sig.PubKey) + if err != nil { + return err + } + anyPk = &anypb.Any{ + TypeUrl: pubKey.TypeUrl, + Value: pubKey.Value, + } + } + + signerInfos[i] = &apitx.SignerInfo{ + PublicKey: anyPk, + ModeInfo: modeInfo, + Sequence: sig.Sequence, + } + } + + f.tx.signerInfos = signerInfos + f.tx.signatures = rawSignatures + + return nil +} + +// getFee computes the transaction fee information. +// It returns a pointer to an apitx.Fee struct containing the fee amount, gas limit, payer, and granter information. +// If the granter or payer addresses are set, it converts them from bytes to string using the addressCodec. +func (f *Factory) getFee() (fee *apitx.Fee, err error) { + granterStr := "" + if f.tx.granter != nil { + granterStr, err = f.ac.BytesToString(f.tx.granter) + if err != nil { + return nil, err + } + } + + payerStr := "" + if f.tx.payer != nil { + payerStr, err = f.ac.BytesToString(f.tx.payer) + if err != nil { + return nil, err + } + } + + fee = &apitx.Fee{ + Amount: f.tx.fees, + GasLimit: f.tx.gasLimit, + Payer: payerStr, + Granter: granterStr, + } + + return fee, nil +} + +// setFeePayer sets the fee payer for the transaction. +func (f *Factory) setFeePayer(feePayer string) error { + if feePayer == "" { + return nil + } + + addr, err := f.ac.StringToBytes(feePayer) + if err != nil { + return err + } + f.tx.payer = addr + return nil +} + +// setFeeGranter sets the fee granter's address in the transaction builder. +// If the feeGranter string is empty, the function returns nil without setting an address. +// It converts the feeGranter string to bytes using the address codec and sets it as the granter address. +// Returns an error if the conversion fails. +func (f *Factory) setFeeGranter(feeGranter string) error { + if feeGranter == "" { + return nil + } + + addr, err := f.ac.StringToBytes(feeGranter) + if err != nil { + return err + } + f.tx.granter = addr + + return nil +} + +// msgsV1toAnyV2 converts a slice of transaction.Msg (v1) to a slice of anypb.Any (v2). +// It first converts each transaction.Msg into a codectypes.Any and then converts +// these into anypb.Any. +func msgsV1toAnyV2(msgs []transaction.Msg) ([]*anypb.Any, error) { + anys := make([]*codectypes.Any, len(msgs)) + for i, msg := range msgs { + anyMsg, err := codectypes.NewAnyWithValue(msg) + if err != nil { + return nil, err + } + anys[i] = anyMsg + } + + return intoAnyV2(anys), nil +} + +// intoAnyV2 converts a slice of codectypes.Any (v1) to a slice of anypb.Any (v2). +func intoAnyV2(v1s []*codectypes.Any) []*anypb.Any { + v2s := make([]*anypb.Any, len(v1s)) + for i, v1 := range v1s { + v2s[i] = &anypb.Any{ + TypeUrl: v1.TypeUrl, + Value: v1.Value, + } + } + return v2s +} + +// checkMultipleSigners checks that there can be maximum one DIRECT signer in +// a tx. +func checkMultipleSigners(tx Tx) error { + directSigners := 0 + sigsV2, err := tx.GetSignatures() + if err != nil { + return err + } + for _, sig := range sigsV2 { + directSigners += countDirectSigners(sig.Data) + if directSigners > 1 { + return errors.New("txs signed with CLI can have maximum 1 DIRECT signer") + } + } + + return nil +} + +// validateMemo validates the memo field. +func validateMemo(memo string) error { + // Prevent simple inclusion of a valid mnemonic in the memo field + if memo != "" && bip39.IsMnemonicValid(strings.ToLower(memo)) { + return errors.New("cannot provide a valid mnemonic seed in the memo field") + } + + return nil +} diff --git a/client/v2/tx/factory_test.go b/client/v2/tx/factory_test.go new file mode 100644 index 000000000000..39f21b38d0df --- /dev/null +++ b/client/v2/tx/factory_test.go @@ -0,0 +1,921 @@ +package tx + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/anypb" + + base "cosmossdk.io/api/cosmos/base/v1beta1" + apitxsigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + "cosmossdk.io/core/transaction" + "cosmossdk.io/x/tx/signing" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + "github.com/cosmos/cosmos-sdk/crypto/keys/multisig" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + countertypes "github.com/cosmos/cosmos-sdk/testutil/x/counter/types" +) + +var ( + signer = "cosmos1zglwfu6xjzvzagqcmvzewyzjp9xwqw5qwrr8n9" + addr, _ = ac.StringToBytes(signer) +) + +func TestFactory_prepareTxParams(t *testing.T) { + tests := []struct { + name string + txParams TxParameters + error bool + }{ + { + name: "no error", + txParams: TxParameters{ + AccountConfig: AccountConfig{ + address: addr, + }, + }, + }, + { + name: "without account", + txParams: TxParameters{}, + error: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + tt.txParams, err = prepareTxParams(tt.txParams, mockAccountRetriever{}, false) + if (err != nil) != tt.error { + t.Errorf("Prepare() error = %v, wantErr %v", err, tt.error) + } + }) + } +} + +func TestFactory_BuildUnsignedTx(t *testing.T) { + tests := []struct { + name string + txParams TxParameters + msgs []transaction.Msg + error bool + }{ + { + name: "no error", + txParams: TxParameters{ + chainID: "demo", + AccountConfig: AccountConfig{ + address: addr, + }, + }, + msgs: []transaction.Msg{ + &countertypes.MsgIncreaseCounter{ + Signer: signer, + Count: 0, + }, + }, + }, + { + name: "fees and gas price provided", + txParams: TxParameters{ + chainID: "demo", + AccountConfig: AccountConfig{ + address: addr, + }, + GasConfig: GasConfig{ + gasPrices: []*base.DecCoin{ + { + Amount: "1000", + Denom: "stake", + }, + }, + }, + FeeConfig: FeeConfig{ + fees: []*base.Coin{ + { + Amount: "1000", + Denom: "stake", + }, + }, + }, + }, + msgs: []transaction.Msg{}, + error: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, tt.txParams) + require.NoError(t, err) + require.NotNil(t, f) + err = f.BuildUnsignedTx(tt.msgs...) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Nil(t, f.tx.signatures) + require.Nil(t, f.tx.signerInfos) + } + }) + } +} + +func TestFactory_calculateGas(t *testing.T) { + tests := []struct { + name string + txParams TxParameters + msgs []transaction.Msg + error bool + }{ + { + name: "no error", + txParams: TxParameters{ + chainID: "demo", + AccountConfig: AccountConfig{ + address: addr, + }, + GasConfig: GasConfig{ + gasAdjustment: 1, + }, + }, + msgs: []transaction.Msg{ + &countertypes.MsgIncreaseCounter{ + Signer: signer, + Count: 0, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, tt.txParams) + require.NoError(t, err) + require.NotNil(t, f) + err = f.calculateGas(tt.msgs...) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotZero(t, f.txParams.GasConfig) + } + }) + } +} + +func TestFactory_Simulate(t *testing.T) { + tests := []struct { + name string + txParams TxParameters + msgs []transaction.Msg + error bool + }{ + { + name: "no error", + txParams: TxParameters{ + chainID: "demo", + AccountConfig: AccountConfig{ + address: addr, + }, + GasConfig: GasConfig{ + gasAdjustment: 1, + }, + }, + msgs: []transaction.Msg{ + &countertypes.MsgIncreaseCounter{ + Signer: signer, + Count: 0, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, tt.txParams) + require.NoError(t, err) + require.NotNil(t, f) + got, got1, err := f.Simulate(tt.msgs...) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, got) + require.NotZero(t, got1) + } + }) + } +} + +func TestFactory_BuildSimTx(t *testing.T) { + tests := []struct { + name string + txParams TxParameters + msgs []transaction.Msg + want []byte + error bool + }{ + { + name: "no error", + txParams: TxParameters{ + chainID: "demo", + AccountConfig: AccountConfig{ + address: addr, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, tt.txParams) + require.NoError(t, err) + require.NotNil(t, f) + got, err := f.BuildSimTx(tt.msgs...) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, got) + } + }) + } +} + +func TestFactory_Sign(t *testing.T) { + tests := []struct { + name string + txParams TxParameters + wantErr bool + }{ + { + name: "no error", + txParams: TxParameters{ + chainID: "demo", + AccountConfig: AccountConfig{ + fromName: "alice", + address: addr, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(setKeyring(), cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, tt.txParams) + require.NoError(t, err) + require.NotNil(t, f) + + err = f.BuildUnsignedTx([]transaction.Msg{ + &countertypes.MsgIncreaseCounter{ + Signer: signer, + Count: 0, + }, + }...) + require.NoError(t, err) + + require.Nil(t, f.tx.signatures) + require.Nil(t, f.tx.signerInfos) + + tx, err := f.sign(context.Background(), true) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + sigs, err := tx.GetSignatures() + require.NoError(t, err) + require.NotNil(t, sigs) + require.NotNil(t, f.tx.signerInfos) + } + }) + } +} + +func TestFactory_getSignBytesAdapter(t *testing.T) { + tests := []struct { + name string + txParams TxParameters + error bool + }{ + { + name: "no error", + txParams: TxParameters{ + chainID: "demo", + signMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + AccountConfig: AccountConfig{ + address: addr, + }, + }, + }, + { + name: "signMode not specified", + txParams: TxParameters{ + chainID: "demo", + AccountConfig: AccountConfig{ + address: addr, + }, + }, + error: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(setKeyring(), cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, tt.txParams) + require.NoError(t, err) + require.NotNil(t, f) + + err = f.BuildUnsignedTx([]transaction.Msg{ + &countertypes.MsgIncreaseCounter{ + Signer: signer, + Count: 0, + }, + }...) + require.NoError(t, err) + + pk, err := f.keybase.GetPubKey("alice") + require.NoError(t, err) + require.NotNil(t, pk) + + addr, err := f.ac.BytesToString(pk.Address()) + require.NoError(t, err) + require.NotNil(t, addr) + + signerData := signing.SignerData{ + Address: addr, + ChainID: f.txParams.chainID, + AccountNumber: 0, + Sequence: 0, + PubKey: &anypb.Any{ + TypeUrl: codectypes.MsgTypeURL(pk), + Value: pk.Bytes(), + }, + } + + got, err := f.getSignBytesAdapter(context.Background(), signerData) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, got) + } + }) + } +} + +func Test_validateMemo(t *testing.T) { + tests := []struct { + name string + memo string + wantErr bool + }{ + { + name: "empty memo", + memo: "", + }, + { + name: "valid memo", + memo: "11245", + }, + { + name: "invalid Memo", + memo: "echo echo echo echo echo echo echo echo echo echo echo echo echo echo echo", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateMemo(tt.memo); (err != nil) != tt.wantErr { + t.Errorf("validateMemo() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFactory_WithFunctions(t *testing.T) { + tests := []struct { + name string + txParams TxParameters + withFunc func(*Factory) + checkFunc func(*Factory) bool + }{ + { + name: "with gas", + txParams: TxParameters{ + AccountConfig: AccountConfig{ + address: addr, + }, + }, + withFunc: func(f *Factory) { + f.WithGas(1000) + }, + checkFunc: func(f *Factory) bool { + return f.txParams.GasConfig.gas == 1000 + }, + }, + { + name: "with sequence", + txParams: TxParameters{ + AccountConfig: AccountConfig{ + address: addr, + }, + }, + withFunc: func(f *Factory) { + f.WithSequence(10) + }, + checkFunc: func(f *Factory) bool { + return f.txParams.AccountConfig.sequence == 10 + }, + }, + { + name: "with account number", + txParams: TxParameters{ + AccountConfig: AccountConfig{ + address: addr, + }, + }, + withFunc: func(f *Factory) { + f.WithAccountNumber(123) + }, + checkFunc: func(f *Factory) bool { + return f.txParams.AccountConfig.accountNumber == 123 + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(setKeyring(), cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, tt.txParams) + require.NoError(t, err) + require.NotNil(t, f) + + tt.withFunc(&f) + require.True(t, tt.checkFunc(&f)) + }) + } +} + +func TestFactory_getTx(t *testing.T) { + tests := []struct { + name string + txSetter func(f *Factory) + checkResult func(Tx) + }{ + { + name: "empty tx", + txSetter: func(f *Factory) { + }, + checkResult: func(tx Tx) { + wTx, ok := tx.(*wrappedTx) + require.True(t, ok) + // require.Equal(t, []*anypb.Any(nil), wTx.Tx.Body.Messages) + require.Nil(t, wTx.Tx.Body.Messages) + require.Empty(t, wTx.Tx.Body.Memo) + require.Equal(t, uint64(0), wTx.Tx.Body.TimeoutHeight) + require.Equal(t, wTx.Tx.Body.Unordered, false) + require.Nil(t, wTx.Tx.Body.ExtensionOptions) + require.Nil(t, wTx.Tx.Body.NonCriticalExtensionOptions) + + require.Nil(t, wTx.Tx.AuthInfo.SignerInfos) + require.Nil(t, wTx.Tx.AuthInfo.Fee.Amount) + require.Equal(t, uint64(0), wTx.Tx.AuthInfo.Fee.GasLimit) + require.Empty(t, wTx.Tx.AuthInfo.Fee.Payer) + require.Empty(t, wTx.Tx.AuthInfo.Fee.Granter) + + require.Nil(t, wTx.Tx.Signatures) + }, + }, + { + name: "full tx", + txSetter: func(f *Factory) { + pk := secp256k1.GenPrivKey().PubKey() + addr, _ := f.ac.BytesToString(pk.Address()) + + f.tx.msgs = []transaction.Msg{&countertypes.MsgIncreaseCounter{ + Signer: addr, + Count: 0, + }} + + err := f.setFeePayer(addr) + require.NoError(t, err) + + f.tx.fees = []*base.Coin{{ + Denom: "cosmos", + Amount: "1000", + }} + + err = f.setSignatures([]Signature{{ + PubKey: pk, + Data: &SingleSignatureData{ + SignMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + Signature: nil, + }, + Sequence: 0, + }}...) + require.NoError(t, err) + }, + checkResult: func(tx Tx) { + wTx, ok := tx.(*wrappedTx) + require.True(t, ok) + require.True(t, len(wTx.Tx.Body.Messages) == 1) + + require.NotNil(t, wTx.Tx.AuthInfo.SignerInfos) + require.NotNil(t, wTx.Tx.AuthInfo.Fee.Amount) + + require.NotNil(t, wTx.Tx.Signatures) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + tt.txSetter(&f) + got, err := f.getTx() + require.NoError(t, err) + require.NotNil(t, got) + tt.checkResult(got) + }) + } +} + +func TestFactory_getFee(t *testing.T) { + tests := []struct { + name string + feeAmount []*base.Coin + feeGranter string + feePayer string + }{ + { + name: "get fee with payer", + feeAmount: []*base.Coin{ + { + Denom: "cosmos", + Amount: "1000", + }, + }, + feeGranter: "", + feePayer: "cosmos1zglwfu6xjzvzagqcmvzewyzjp9xwqw5qwrr8n9", + }, + { + name: "get fee with granter", + feeAmount: []*base.Coin{ + { + Denom: "cosmos", + Amount: "1000", + }, + }, + feeGranter: "cosmos1zglwfu6xjzvzagqcmvzewyzjp9xwqw5qwrr8n9", + feePayer: "", + }, + { + name: "get fee with granter and granter", + feeAmount: []*base.Coin{ + { + Denom: "cosmos", + Amount: "1000", + }, + }, + feeGranter: "cosmos1zglwfu6xjzvzagqcmvzewyzjp9xwqw5qwrr8n9", + feePayer: "cosmos1zglwfu6xjzvzagqcmvzewyzjp9xwqw5qwrr8n9", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + f.tx.fees = tt.feeAmount + err = f.setFeeGranter(tt.feeGranter) + require.NoError(t, err) + err = f.setFeePayer(tt.feePayer) + require.NoError(t, err) + + fee, err := f.getFee() + require.NoError(t, err) + require.NotNil(t, fee) + + require.Equal(t, fee.Amount, tt.feeAmount) + require.Equal(t, fee.Granter, tt.feeGranter) + require.Equal(t, fee.Payer, tt.feePayer) + }) + } +} + +func TestFactory_getSigningTxData(t *testing.T) { + tests := []struct { + name string + txSetter func(f *Factory) + }{ + { + name: "empty tx", + txSetter: func(f *Factory) {}, + }, + { + name: "full tx", + txSetter: func(f *Factory) { + pk := secp256k1.GenPrivKey().PubKey() + addr, _ := ac.BytesToString(pk.Address()) + + f.tx.msgs = []transaction.Msg{&countertypes.MsgIncreaseCounter{ + Signer: addr, + Count: 0, + }} + + err := f.setFeePayer(addr) + require.NoError(t, err) + + f.tx.fees = []*base.Coin{{ + Denom: "cosmos", + Amount: "1000", + }} + + err = f.setSignatures([]Signature{{ + PubKey: pk, + Data: &SingleSignatureData{ + SignMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + Signature: []byte("signature"), + }, + Sequence: 0, + }}...) + require.NoError(t, err) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + tt.txSetter(&f) + got, err := f.getSigningTxData() + require.NoError(t, err) + require.NotNil(t, got) + }) + } +} + +func TestFactoryr_setMsgs(t *testing.T) { + tests := []struct { + name string + msgs []transaction.Msg + wantErr bool + }{ + { + name: "set msgs", + msgs: []transaction.Msg{ + &countertypes.MsgIncreaseCounter{ + Signer: "cosmos1zglwfu6xjzvzagqcmvzewyzjp9xwqw5qwrr8n9", + Count: 0, + }, + &countertypes.MsgIncreaseCounter{ + Signer: "cosmos1zglwfu6xjzvzagqcmvzewyzjp9xwqw5qwrr8n9", + Count: 1, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + f.tx.msgs = tt.msgs + require.NoError(t, err) + require.Equal(t, len(tt.msgs), len(f.tx.msgs)) + + for i, msg := range tt.msgs { + require.Equal(t, msg, f.tx.msgs[i]) + } + }) + } +} + +func TestFactory_SetMemo(t *testing.T) { + tests := []struct { + name string + memo string + }{ + { + name: "set memo", + memo: "test", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + f.tx.memo = tt.memo + require.Equal(t, f.tx.memo, tt.memo) + }) + } +} + +func TestFactory_SetFeeAmount(t *testing.T) { + tests := []struct { + name string + coins []*base.Coin + }{ + { + name: "set coins", + coins: []*base.Coin{ + { + Denom: "cosmos", + Amount: "1000", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + f.tx.fees = tt.coins + require.Equal(t, len(tt.coins), len(f.tx.fees)) + + for i, coin := range tt.coins { + require.Equal(t, coin.Amount, f.tx.fees[i].Amount) + } + }) + } +} + +func TestFactory_SetGasLimit(t *testing.T) { + tests := []struct { + name string + gasLimit uint64 + }{ + { + name: "set gas limit", + gasLimit: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + f.tx.gasLimit = tt.gasLimit + require.Equal(t, f.tx.gasLimit, tt.gasLimit) + }) + } +} + +func TestFactory_SetUnordered(t *testing.T) { + tests := []struct { + name string + unordered bool + }{ + { + name: "unordered", + unordered: true, + }, + { + name: "not unordered", + unordered: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + f.tx.unordered = tt.unordered + require.Equal(t, f.tx.unordered, tt.unordered) + }) + } +} + +func TestFactory_setSignatures(t *testing.T) { + tests := []struct { + name string + signatures func() []Signature + }{ + { + name: "set empty single signature", + signatures: func() []Signature { + return []Signature{{ + PubKey: secp256k1.GenPrivKey().PubKey(), + Data: &SingleSignatureData{ + SignMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + Signature: nil, + }, + Sequence: 0, + }} + }, + }, + { + name: "set single signature", + signatures: func() []Signature { + return []Signature{{ + PubKey: secp256k1.GenPrivKey().PubKey(), + Data: &SingleSignatureData{ + SignMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + Signature: []byte("signature"), + }, + Sequence: 0, + }} + }, + }, + { + name: "set empty multi signature", + signatures: func() []Signature { + return []Signature{{ + PubKey: multisig.NewLegacyAminoPubKey(1, []cryptotypes.PubKey{secp256k1.GenPrivKey().PubKey()}), + Data: &MultiSignatureData{ + BitArray: nil, + Signatures: []SignatureData{ + &SingleSignatureData{ + SignMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + Signature: nil, + }, + }, + }, + Sequence: 0, + }} + }, + }, + { + name: "set multi signature", + signatures: func() []Signature { + return []Signature{{ + PubKey: multisig.NewLegacyAminoPubKey(1, []cryptotypes.PubKey{secp256k1.GenPrivKey().PubKey()}), + Data: &MultiSignatureData{ + BitArray: nil, + Signatures: []SignatureData{ + &SingleSignatureData{ + SignMode: apitxsigning.SignMode_SIGN_MODE_DIRECT, + Signature: []byte("signature"), + }, + }, + }, + Sequence: 0, + }} + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cryptocodec.RegisterInterfaces(cdc.InterfaceRegistry()) + f, err := NewFactory(keybase, cdc, mockAccountRetriever{}, txConf, ac, mockClientConn{}, TxParameters{}) + require.NoError(t, err) + sigs := tt.signatures() + err = f.setSignatures(sigs...) + require.NoError(t, err) + tx, err := f.getTx() + require.NoError(t, err) + signatures, err := tx.GetSignatures() + require.NoError(t, err) + require.Equal(t, len(sigs), len(signatures)) + for i := range signatures { + require.Equal(t, sigs[i].PubKey, signatures[i].PubKey) + } + }) + } +} + +/////////////////////// + +func Test_msgsV1toAnyV2(t *testing.T) { + tests := []struct { + name string + msgs []transaction.Msg + }{ + { + name: "convert msgV1 to V2", + msgs: []transaction.Msg{ + &countertypes.MsgIncreaseCounter{ + Signer: "cosmos1zglwfu6xjzvzagqcmvzewyzjp9xwqw5qwrr8n9", + Count: 0, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := msgsV1toAnyV2(tt.msgs) + require.NoError(t, err) + require.NotNil(t, got) + }) + } +} + +func Test_intoAnyV2(t *testing.T) { + tests := []struct { + name string + msgs []*codectypes.Any + }{ + { + name: "any to v2", + msgs: []*codectypes.Any{ + { + TypeUrl: "/random/msg", + Value: []byte("random message"), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := intoAnyV2(tt.msgs) + require.NotNil(t, got) + require.Equal(t, len(got), len(tt.msgs)) + for i, msg := range got { + require.Equal(t, msg.TypeUrl, tt.msgs[i].TypeUrl) + require.Equal(t, msg.Value, tt.msgs[i].Value) + } + }) + } +} diff --git a/client/v2/tx/flags.go b/client/v2/tx/flags.go new file mode 100644 index 000000000000..6ef8584042f7 --- /dev/null +++ b/client/v2/tx/flags.go @@ -0,0 +1,52 @@ +package tx + +import ( + "fmt" + "strconv" +) + +// Flag constants for transaction-related flags +const ( + defaultGasLimit = 200000 + gasFlagAuto = "auto" + + flagTimeoutTimestamp = "timeout-timestamp" + flagChainID = "chain-id" + flagNote = "note" + flagSignMode = "sign-mode" + flagAccountNumber = "account-number" + flagSequence = "sequence" + flagFrom = "from" + flagDryRun = "dry-run" + flagGas = "gas" + flagGasAdjustment = "gas-adjustment" + flagGasPrices = "gas-prices" + flagFees = "fees" + flagFeePayer = "fee-payer" + flagFeeGranter = "fee-granter" + flagUnordered = "unordered" + flagOffline = "offline" + flagGenerateOnly = "generate-only" +) + +// parseGasSetting parses a string gas value. The value may either be 'auto', +// which indicates a transaction should be executed in simulate mode to +// automatically find a sufficient gas value, or a string integer. It returns an +// error if a string integer is provided which cannot be parsed. +func parseGasSetting(gasStr string) (bool, uint64, error) { + switch gasStr { + case "": + return false, defaultGasLimit, nil + + case gasFlagAuto: + return true, 0, nil + + default: + gas, err := strconv.ParseUint(gasStr, 10, 64) + if err != nil { + return false, 0, fmt.Errorf("gas must be either integer or %s", gasFlagAuto) + } + + return false, gas, nil + } +} diff --git a/client/v2/tx/signature.go b/client/v2/tx/signature.go new file mode 100644 index 000000000000..66235380072b --- /dev/null +++ b/client/v2/tx/signature.go @@ -0,0 +1,197 @@ +package tx + +import ( + "errors" + "fmt" + + apicrypto "cosmossdk.io/api/cosmos/crypto/multisig/v1beta1" + apitxsigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + apitx "cosmossdk.io/api/cosmos/tx/v1beta1" + + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" +) + +// Signature holds the necessary components to verify transaction signatures. +type Signature struct { + PubKey cryptotypes.PubKey // Public key for signature verification. + Data SignatureData // Signature data containing the actual signatures. + Sequence uint64 // Account sequence, relevant for SIGN_MODE_DIRECT. +} + +// SignatureData defines an interface for different signature data types. +type SignatureData interface { + isSignatureData() +} + +// SingleSignatureData stores a single signer's signature and its mode. +type SingleSignatureData struct { + SignMode apitxsigning.SignMode // Mode of the signature. + Signature []byte // Actual binary signature. +} + +// MultiSignatureData encapsulates signatures from a multisig transaction. +type MultiSignatureData struct { + BitArray *apicrypto.CompactBitArray // Bitmap of signers. + Signatures []SignatureData // Individual signatures. +} + +func (m *SingleSignatureData) isSignatureData() {} +func (m *MultiSignatureData) isSignatureData() {} + +// signatureDataToModeInfoAndSig converts SignatureData to ModeInfo and its corresponding raw signature. +func signatureDataToModeInfoAndSig(data SignatureData) (*apitx.ModeInfo, []byte) { + if data == nil { + return nil, nil + } + + switch data := data.(type) { + case *SingleSignatureData: + return &apitx.ModeInfo{ + Sum: &apitx.ModeInfo_Single_{ + Single: &apitx.ModeInfo_Single{Mode: data.SignMode}, + }, + }, data.Signature + case *MultiSignatureData: + modeInfos := make([]*apitx.ModeInfo, len(data.Signatures)) + sigs := make([][]byte, len(data.Signatures)) + + for i, d := range data.Signatures { + modeInfos[i], sigs[i] = signatureDataToModeInfoAndSig(d) + } + + multisig := cryptotypes.MultiSignature{Signatures: sigs} + sig, err := multisig.Marshal() + if err != nil { + panic(err) + } + + return &apitx.ModeInfo{ + Sum: &apitx.ModeInfo_Multi_{ + Multi: &apitx.ModeInfo_Multi{ + Bitarray: data.BitArray, + ModeInfos: modeInfos, + }, + }, + }, sig + default: + panic(fmt.Sprintf("unexpected signature data type %T", data)) + } +} + +// modeInfoAndSigToSignatureData converts ModeInfo and a raw signature to SignatureData. +func modeInfoAndSigToSignatureData(modeInfo *apitx.ModeInfo, sig []byte) (SignatureData, error) { + switch mi := modeInfo.Sum.(type) { + case *apitx.ModeInfo_Single_: + return &SingleSignatureData{ + SignMode: mi.Single.Mode, + Signature: sig, + }, nil + + case *apitx.ModeInfo_Multi_: + multi := mi.Multi + + sigs, err := decodeMultiSignatures(sig) + if err != nil { + return nil, err + } + + sigsV2 := make([]SignatureData, len(sigs)) + for i, mi := range multi.ModeInfos { + sigsV2[i], err = modeInfoAndSigToSignatureData(mi, sigs[i]) + if err != nil { + return nil, err + } + } + return &MultiSignatureData{ + BitArray: multi.Bitarray, + Signatures: sigsV2, + }, nil + } + + return nil, fmt.Errorf("unsupported ModeInfo type %T", modeInfo) +} + +// decodeMultiSignatures decodes a byte array into individual signatures. +func decodeMultiSignatures(bz []byte) ([][]byte, error) { + multisig := cryptotypes.MultiSignature{} + + err := multisig.Unmarshal(bz) + if err != nil { + return nil, err + } + + if len(multisig.XXX_unrecognized) > 0 { + return nil, errors.New("unrecognized fields in MultiSignature") + } + return multisig.Signatures, nil +} + +// signatureDataToProto converts a SignatureData interface to a protobuf SignatureDescriptor_Data. +// This function supports both SingleSignatureData and MultiSignatureData types. +// For SingleSignatureData, it directly maps the signature mode and signature bytes to the protobuf structure. +// For MultiSignatureData, it recursively converts each signature in the collection to the corresponding protobuf structure. +func signatureDataToProto(data SignatureData) (*apitxsigning.SignatureDescriptor_Data, error) { + switch data := data.(type) { + case *SingleSignatureData: + // Handle single signature data conversion. + return &apitxsigning.SignatureDescriptor_Data{ + Sum: &apitxsigning.SignatureDescriptor_Data_Single_{ + Single: &apitxsigning.SignatureDescriptor_Data_Single{ + Mode: data.SignMode, + Signature: data.Signature, + }, + }, + }, nil + case *MultiSignatureData: + var err error + descDatas := make([]*apitxsigning.SignatureDescriptor_Data, len(data.Signatures)) + + for i, j := range data.Signatures { + descDatas[i], err = signatureDataToProto(j) + if err != nil { + return nil, err + } + } + return &apitxsigning.SignatureDescriptor_Data{ + Sum: &apitxsigning.SignatureDescriptor_Data_Multi_{ + Multi: &apitxsigning.SignatureDescriptor_Data_Multi{ + Bitarray: data.BitArray, + Signatures: descDatas, + }, + }, + }, nil + } + + // Return an error if the data type is not supported. + return nil, fmt.Errorf("unexpected signature data type %T", data) +} + +// SignatureDataFromProto converts a protobuf SignatureDescriptor_Data to a SignatureData interface. +// This function supports both Single and Multi signature data types. +func SignatureDataFromProto(descData *apitxsigning.SignatureDescriptor_Data) (SignatureData, error) { + switch descData := descData.Sum.(type) { + case *apitxsigning.SignatureDescriptor_Data_Single_: + return &SingleSignatureData{ + SignMode: descData.Single.Mode, + Signature: descData.Single.Signature, + }, nil + case *apitxsigning.SignatureDescriptor_Data_Multi_: + var err error + multi := descData.Multi + data := make([]SignatureData, len(multi.Signatures)) + + for i, j := range multi.Signatures { + data[i], err = SignatureDataFromProto(j) + if err != nil { + return nil, err + } + } + + return &MultiSignatureData{ + BitArray: multi.Bitarray, + Signatures: data, + }, nil + } + + return nil, fmt.Errorf("unexpected signature data type %T", descData) +} diff --git a/client/v2/tx/signature_test.go b/client/v2/tx/signature_test.go new file mode 100644 index 000000000000..dd78add38d32 --- /dev/null +++ b/client/v2/tx/signature_test.go @@ -0,0 +1,143 @@ +package tx + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" + + apimultisig "cosmossdk.io/api/cosmos/crypto/multisig/v1beta1" + apisigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + apitx "cosmossdk.io/api/cosmos/tx/v1beta1" +) + +func TestSignatureDataToModeInfoAndSig(t *testing.T) { + tests := []struct { + name string + data SignatureData + mIResult *apitx.ModeInfo + sigResult []byte + }{ + { + name: "single signature", + data: &SingleSignatureData{ + SignMode: apisigning.SignMode_SIGN_MODE_DIRECT, + Signature: []byte("signature"), + }, + mIResult: &apitx.ModeInfo{ + Sum: &apitx.ModeInfo_Single_{ + Single: &apitx.ModeInfo_Single{Mode: apisigning.SignMode_SIGN_MODE_DIRECT}, + }, + }, + sigResult: []byte("signature"), + }, + { + name: "multi signature", + data: &MultiSignatureData{ + BitArray: nil, + Signatures: []SignatureData{ + &SingleSignatureData{ + SignMode: apisigning.SignMode_SIGN_MODE_DIRECT, + Signature: []byte("signature"), + }, + }, + }, + mIResult: &apitx.ModeInfo{ + Sum: &apitx.ModeInfo_Multi_{ + Multi: &apitx.ModeInfo_Multi{ + Bitarray: nil, + ModeInfos: []*apitx.ModeInfo{ + { + Sum: &apitx.ModeInfo_Single_{ + Single: &apitx.ModeInfo_Single{Mode: apisigning.SignMode_SIGN_MODE_DIRECT}, + }, + }, + }, + }, + }, + }, + sigResult: []byte("\n\tsignature"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modeInfo, signature := signatureDataToModeInfoAndSig(tt.data) + require.Equal(t, tt.mIResult, modeInfo) + require.Equal(t, tt.sigResult, signature) + }) + } +} + +func TestModeInfoAndSigToSignatureData(t *testing.T) { + type args struct { + modeInfo func() *apitx.ModeInfo + sig []byte + } + tests := []struct { + name string + args args + want SignatureData + wantErr bool + }{ + { + name: "to SingleSignatureData", + args: args{ + modeInfo: func() *apitx.ModeInfo { + return &apitx.ModeInfo{ + Sum: &apitx.ModeInfo_Single_{ + Single: &apitx.ModeInfo_Single{Mode: apisigning.SignMode_SIGN_MODE_DIRECT}, + }, + } + }, + sig: []byte("signature"), + }, + want: &SingleSignatureData{ + SignMode: apisigning.SignMode_SIGN_MODE_DIRECT, + Signature: []byte("signature"), + }, + }, + { + name: "to MultiSignatureData", + args: args{ + modeInfo: func() *apitx.ModeInfo { + return &apitx.ModeInfo{ + Sum: &apitx.ModeInfo_Multi_{ + Multi: &apitx.ModeInfo_Multi{ + Bitarray: &apimultisig.CompactBitArray{}, + ModeInfos: []*apitx.ModeInfo{ + { + Sum: &apitx.ModeInfo_Single_{ + Single: &apitx.ModeInfo_Single{Mode: apisigning.SignMode_SIGN_MODE_DIRECT}, + }, + }, + }, + }, + }, + } + }, + sig: []byte("\n\tsignature"), + }, + want: &MultiSignatureData{ // Changed from SingleSignatureData to MultiSignatureData + BitArray: &apimultisig.CompactBitArray{}, + Signatures: []SignatureData{ + &SingleSignatureData{ + SignMode: apisigning.SignMode_SIGN_MODE_DIRECT, + Signature: []byte("signature"), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := modeInfoAndSigToSignatureData(tt.args.modeInfo(), tt.args.sig) + if (err != nil) != tt.wantErr { + t.Errorf("ModeInfoAndSigToSignatureData() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ModeInfoAndSigToSignatureData() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/client/v2/tx/tx.go b/client/v2/tx/tx.go new file mode 100644 index 000000000000..64e0199d8172 --- /dev/null +++ b/client/v2/tx/tx.go @@ -0,0 +1,229 @@ +package tx + +import ( + "bufio" + "errors" + "fmt" + "os" + + "github.com/cosmos/gogoproto/proto" + "github.com/spf13/pflag" + + apitxsigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + "cosmossdk.io/client/v2/internal/account" + "cosmossdk.io/core/transaction" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/input" + "github.com/cosmos/cosmos-sdk/crypto/keyring" +) + +// GenerateOrBroadcastTxCLI will either generate and print an unsigned transaction +// or sign it and broadcast it returning an error upon failure. +func GenerateOrBroadcastTxCLI(ctx client.Context, flagSet *pflag.FlagSet, msgs ...transaction.Msg) error { + if err := validateMessages(msgs...); err != nil { + return err + } + + txf, err := newFactory(ctx, flagSet) + if err != nil { + return err + } + + genOnly, _ := flagSet.GetBool(flagGenerateOnly) + if genOnly { + return generateOnly(ctx, txf, msgs...) + } + + isDryRun, _ := flagSet.GetBool(flagDryRun) + if isDryRun { + return dryRun(txf, msgs...) + } + + return BroadcastTx(ctx, txf, msgs...) +} + +// newFactory creates a new transaction Factory based on the provided context and flag set. +// It initializes a new CLI keyring, extracts transaction parameters from the flag set, +// configures transaction settings, and sets up an account retriever for the transaction Factory. +func newFactory(ctx client.Context, flagSet *pflag.FlagSet) (Factory, error) { + k, err := keyring.NewAutoCLIKeyring(ctx.Keyring, ctx.AddressCodec) + if err != nil { + return Factory{}, err + } + + txConfig, err := NewTxConfig(ConfigOptions{ + AddressCodec: ctx.AddressCodec, + Cdc: ctx.Codec, + ValidatorAddressCodec: ctx.ValidatorAddressCodec, + EnablesSignModes: ctx.TxConfig.SignModeHandler().SupportedModes(), + }) + if err != nil { + return Factory{}, err + } + + accRetriever := account.NewAccountRetriever(ctx.AddressCodec, ctx, ctx.InterfaceRegistry) + + txf, err := NewFactoryFromFlagSet(flagSet, k, ctx.Codec, accRetriever, txConfig, ctx.AddressCodec, ctx) + if err != nil { + return Factory{}, err + } + + return txf, nil +} + +// validateMessages validates all msgs before generating or broadcasting the tx. +// We were calling ValidateBasic separately in each CLI handler before. +// Right now, we're factorizing that call inside this function. +// ref: https://github.com/cosmos/cosmos-sdk/pull/9236#discussion_r623803504 +func validateMessages(msgs ...transaction.Msg) error { + for _, msg := range msgs { + m, ok := msg.(HasValidateBasic) + if !ok { + continue + } + + if err := m.ValidateBasic(); err != nil { + return err + } + } + + return nil +} + +// generateOnly prepares the transaction and prints the unsigned transaction string. +// It first calls Prepare on the transaction factory to set up any necessary pre-conditions. +// If preparation is successful, it generates an unsigned transaction string using the provided messages. +func generateOnly(ctx client.Context, txf Factory, msgs ...transaction.Msg) error { + uTx, err := txf.UnsignedTxString(msgs...) + if err != nil { + return err + } + + return ctx.PrintString(uTx) +} + +// dryRun performs a dry run of the transaction to estimate the gas required. +// It prepares the transaction factory and simulates the transaction with the provided messages. +func dryRun(txf Factory, msgs ...transaction.Msg) error { + _, gas, err := txf.Simulate(msgs...) + if err != nil { + return err + } + + _, err = fmt.Fprintf(os.Stderr, "%s\n", GasEstimateResponse{GasEstimate: gas}) + return err +} + +// SimulateTx simulates a tx and returns the simulation response obtained by the query. +func SimulateTx(ctx client.Context, flagSet *pflag.FlagSet, msgs ...transaction.Msg) (proto.Message, error) { + txf, err := newFactory(ctx, flagSet) + if err != nil { + return nil, err + } + + simulation, _, err := txf.Simulate(msgs...) + return simulation, err +} + +// BroadcastTx attempts to generate, sign and broadcast a transaction with the +// given set of messages. It will also simulate gas requirements if necessary. +// It will return an error upon failure. +func BroadcastTx(clientCtx client.Context, txf Factory, msgs ...transaction.Msg) error { + if txf.simulateAndExecute() { + err := txf.calculateGas(msgs...) + if err != nil { + return err + } + } + + err := txf.BuildUnsignedTx(msgs...) + if err != nil { + return err + } + + if !clientCtx.SkipConfirm { + encoder := txf.txConfig.TxJSONEncoder() + if encoder == nil { + return errors.New("failed to encode transaction: tx json encoder is nil") + } + + unsigTx, err := txf.getTx() + if err != nil { + return err + } + txBytes, err := encoder(unsigTx) + if err != nil { + return fmt.Errorf("failed to encode transaction: %w", err) + } + + if err := clientCtx.PrintRaw(txBytes); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\n%s\n", err, txBytes) + } + + buf := bufio.NewReader(os.Stdin) + ok, err := input.GetConfirmation("confirm transaction before signing and broadcasting", buf, os.Stderr) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\ncanceled transaction\n", err) + return err + } + if !ok { + _, _ = fmt.Fprintln(os.Stderr, "canceled transaction") + return nil + } + } + + signedTx, err := txf.sign(clientCtx.CmdContext, true) + if err != nil { + return err + } + + txBytes, err := txf.txConfig.TxEncoder()(signedTx) + if err != nil { + return err + } + + // broadcast to a CometBFT node + res, err := clientCtx.BroadcastTx(txBytes) + if err != nil { + return err + } + + return clientCtx.PrintProto(res) +} + +// countDirectSigners counts the number of DIRECT signers in a signature data. +func countDirectSigners(sigData SignatureData) int { + switch data := sigData.(type) { + case *SingleSignatureData: + if data.SignMode == apitxsigning.SignMode_SIGN_MODE_DIRECT { + return 1 + } + + return 0 + case *MultiSignatureData: + directSigners := 0 + for _, d := range data.Signatures { + directSigners += countDirectSigners(d) + } + + return directSigners + default: + panic("unreachable case") + } +} + +// getSignMode returns the corresponding apitxsigning.SignMode based on the provided mode string. +func getSignMode(mode string) apitxsigning.SignMode { + switch mode { + case "direct": + return apitxsigning.SignMode_SIGN_MODE_DIRECT + case "direct-aux": + return apitxsigning.SignMode_SIGN_MODE_DIRECT_AUX + case "amino-json": + return apitxsigning.SignMode_SIGN_MODE_LEGACY_AMINO_JSON + case "textual": + return apitxsigning.SignMode_SIGN_MODE_TEXTUAL + } + return apitxsigning.SignMode_SIGN_MODE_UNSPECIFIED +} diff --git a/client/v2/tx/types.go b/client/v2/tx/types.go new file mode 100644 index 000000000000..ee60c27065b6 --- /dev/null +++ b/client/v2/tx/types.go @@ -0,0 +1,214 @@ +package tx + +import ( + "fmt" + "time" + + "github.com/spf13/pflag" + "google.golang.org/protobuf/types/known/anypb" + + base "cosmossdk.io/api/cosmos/base/v1beta1" + apitxsigning "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + apitx "cosmossdk.io/api/cosmos/tx/v1beta1" + keyring2 "cosmossdk.io/client/v2/autocli/keyring" + "cosmossdk.io/client/v2/internal/coins" + "cosmossdk.io/core/address" + "cosmossdk.io/core/transaction" + + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" +) + +// HasValidateBasic is a copy of types.HasValidateBasic to avoid sdk import. +type HasValidateBasic interface { + // ValidateBasic does a simple validation check that + // doesn't require access to any other information. + ValidateBasic() error +} + +// TxParameters defines the parameters required for constructing a transaction. +type TxParameters struct { + timeoutTimestamp time.Time // timeoutTimestamp indicates a timestamp after which the transaction is no longer valid. + chainID string // chainID specifies the unique identifier of the blockchain where the transaction will be processed. + memo string // memo contains any arbitrary memo to be attached to the transaction. + signMode apitxsigning.SignMode // signMode determines the signing mode to be used for the transaction. + + AccountConfig // AccountConfig includes information about the transaction originator's account. + GasConfig // GasConfig specifies the gas settings for the transaction. + FeeConfig // FeeConfig details the fee associated with the transaction. + ExecutionOptions // ExecutionOptions includes settings that modify how the transaction is executed. +} + +// AccountConfig defines the 'account' related fields in a transaction. +type AccountConfig struct { + // accountNumber is the unique identifier for the account. + accountNumber uint64 + // sequence is the sequence number of the transaction. + sequence uint64 + // fromName is the name of the account sending the transaction. + fromName string + // fromAddress is the address of the account sending the transaction. + fromAddress string + // address is the byte representation of the account address. + address []byte +} + +// GasConfig defines the 'gas' related fields in a transaction. +// GasConfig defines the gas-related settings for a transaction. +type GasConfig struct { + gas uint64 // gas is the amount of gas requested for the transaction. + gasAdjustment float64 // gasAdjustment is the factor by which the estimated gas is multiplied to calculate the final gas limit. + gasPrices []*base.DecCoin // gasPrices is a list of denominations of DecCoin used to calculate the fee paid for the gas. +} + +// NewGasConfig creates a new GasConfig with the specified gas, gasAdjustment, and gasPrices. +// If the provided gas value is zero, it defaults to a predefined value (defaultGas). +// The gasPrices string is parsed into a slice of DecCoin. +func NewGasConfig(gas uint64, gasAdjustment float64, gasPrices string) (GasConfig, error) { + parsedGasPrices, err := coins.ParseDecCoins(gasPrices) + if err != nil { + return GasConfig{}, err + } + + return GasConfig{ + gas: gas, + gasAdjustment: gasAdjustment, + gasPrices: parsedGasPrices, + }, nil +} + +// FeeConfig holds the fee details for a transaction. +type FeeConfig struct { + fees []*base.Coin // fees are the amounts paid for the transaction. + feePayer string // feePayer is the account responsible for paying the fees. + feeGranter string // feeGranter is the account granting the fee payment if different from the payer. +} + +// NewFeeConfig creates a new FeeConfig with the specified fees, feePayer, and feeGranter. +// It parses the fees string into a slice of Coin, handling normalization. +func NewFeeConfig(fees, feePayer, feeGranter string) (FeeConfig, error) { + parsedFees, err := coins.ParseCoinsNormalized(fees) + if err != nil { + return FeeConfig{}, err + } + + return FeeConfig{ + fees: parsedFees, + feePayer: feePayer, + feeGranter: feeGranter, + }, nil +} + +// ExecutionOptions defines the transaction execution options ran by the client +type ExecutionOptions struct { + unordered bool // unordered indicates if the transaction execution order is not guaranteed. + simulateAndExecute bool // simulateAndExecute indicates if the transaction should be simulated before execution. +} + +// GasEstimateResponse defines a response definition for tx gas estimation. +type GasEstimateResponse struct { + GasEstimate uint64 `json:"gas_estimate" yaml:"gas_estimate"` +} + +func (gr GasEstimateResponse) String() string { + return fmt.Sprintf("gas estimate: %d", gr.GasEstimate) +} + +// txState represents the internal state of a transaction. +type txState struct { + msgs []transaction.Msg + timeoutHeight uint64 + timeoutTimestamp time.Time + granter []byte + payer []byte + unordered bool + memo string + gasLimit uint64 + fees []*base.Coin + signerInfos []*apitx.SignerInfo + signatures [][]byte + + extensionOptions []*anypb.Any + nonCriticalExtensionOptions []*anypb.Any +} + +// Tx defines the interface for transaction operations. +type Tx interface { + transaction.Tx + + // GetSigners fetches the addresses of the signers of the transaction. + GetSigners() ([][]byte, error) + // GetPubKeys retrieves the public keys of the signers of the transaction. + GetPubKeys() ([]cryptotypes.PubKey, error) + // GetSignatures fetches the signatures attached to the transaction. + GetSignatures() ([]Signature, error) +} + +// txParamsFromFlagSet extracts the transaction parameters from the provided FlagSet. +func txParamsFromFlagSet(flags *pflag.FlagSet, keybase keyring2.Keyring, ac address.Codec) (params TxParameters, err error) { + timestampUnix, _ := flags.GetInt64(flagTimeoutTimestamp) + timeoutTimestamp := time.Unix(timestampUnix, 0) + chainID, _ := flags.GetString(flagChainID) + memo, _ := flags.GetString(flagNote) + signMode, _ := flags.GetString(flagSignMode) + + accNumber, _ := flags.GetUint64(flagAccountNumber) + sequence, _ := flags.GetUint64(flagSequence) + from, _ := flags.GetString(flagFrom) + + var fromName, fromAddress string + var addr []byte + isDryRun, _ := flags.GetBool(flagDryRun) + if isDryRun { + addr, err = ac.StringToBytes(from) + } else { + fromName, fromAddress, _, err = keybase.KeyInfo(from) + if err == nil { + addr, err = ac.StringToBytes(fromAddress) + } + } + if err != nil { + return params, err + } + + gas, _ := flags.GetString(flagGas) + simulate, gasValue, _ := parseGasSetting(gas) + gasAdjustment, _ := flags.GetFloat64(flagGasAdjustment) + gasPrices, _ := flags.GetString(flagGasPrices) + + fees, _ := flags.GetString(flagFees) + feePayer, _ := flags.GetString(flagFeePayer) + feeGrater, _ := flags.GetString(flagFeeGranter) + + unordered, _ := flags.GetBool(flagUnordered) + + gasConfig, err := NewGasConfig(gasValue, gasAdjustment, gasPrices) + if err != nil { + return params, err + } + feeConfig, err := NewFeeConfig(fees, feePayer, feeGrater) + if err != nil { + return params, err + } + + txParams := TxParameters{ + timeoutTimestamp: timeoutTimestamp, + chainID: chainID, + memo: memo, + signMode: getSignMode(signMode), + AccountConfig: AccountConfig{ + accountNumber: accNumber, + sequence: sequence, + fromName: fromName, + fromAddress: fromAddress, + address: addr, + }, + GasConfig: gasConfig, + FeeConfig: feeConfig, + ExecutionOptions: ExecutionOptions{ + unordered: unordered, + simulateAndExecute: simulate, + }, + } + + return txParams, nil +} diff --git a/client/v2/tx/wrapper.go b/client/v2/tx/wrapper.go new file mode 100644 index 000000000000..fbcf62126bfc --- /dev/null +++ b/client/v2/tx/wrapper.go @@ -0,0 +1,115 @@ +package tx + +import ( + "fmt" + "reflect" + "strings" + + "github.com/cosmos/gogoproto/proto" + "google.golang.org/protobuf/types/known/anypb" + + "cosmossdk.io/core/transaction" + "cosmossdk.io/x/tx/decode" + + "github.com/cosmos/cosmos-sdk/codec" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" +) + +var ( + _ transaction.Tx = wrappedTx{} + _ Tx = wrappedTx{} +) + +// wrappedTx wraps a transaction and provides a codec for binary encoding/decoding. +type wrappedTx struct { + *decode.DecodedTx + + cdc codec.BinaryCodec +} + +func newWrapperTx(cdc codec.BinaryCodec, decodedTx *decode.DecodedTx) *wrappedTx { + return &wrappedTx{ + DecodedTx: decodedTx, + cdc: cdc, + } +} + +// GetSigners fetches the addresses of the signers of the transaction. +func (w wrappedTx) GetSigners() ([][]byte, error) { + return w.Signers, nil +} + +// GetPubKeys retrieves the public keys of the signers from the transaction's SignerInfos. +func (w wrappedTx) GetPubKeys() ([]cryptotypes.PubKey, error) { + signerInfos := w.Tx.AuthInfo.SignerInfos + pks := make([]cryptotypes.PubKey, len(signerInfos)) + + for i, si := range signerInfos { + // NOTE: it is okay to leave this nil if there is no PubKey in the SignerInfo. + // PubKey's can be left unset in SignerInfo. + if si.PublicKey == nil { + continue + } + maybePk, err := w.decodeAny(si.PublicKey) + if err != nil { + return nil, err + } + pk, ok := maybePk.(cryptotypes.PubKey) + if !ok { + return nil, fmt.Errorf("invalid public key type: %T", maybePk) + } + pks[i] = pk + } + + return pks, nil +} + +// GetSignatures fetches the signatures attached to the transaction. +func (w wrappedTx) GetSignatures() ([]Signature, error) { + signerInfos := w.Tx.AuthInfo.SignerInfos + sigs := w.Tx.Signatures + + pubKeys, err := w.GetPubKeys() + if err != nil { + return nil, err + } + signatures := make([]Signature, len(sigs)) + + for i, si := range signerInfos { + if si.ModeInfo == nil || si.ModeInfo.Sum == nil { + signatures[i] = Signature{ + PubKey: pubKeys[i], + } + } else { + sigData, err := modeInfoAndSigToSignatureData(si.ModeInfo, sigs[i]) + if err != nil { + return nil, err + } + signatures[i] = Signature{ + PubKey: pubKeys[i], + Data: sigData, + Sequence: si.GetSequence(), + } + } + } + + return signatures, nil +} + +// decodeAny decodes a protobuf Any message into a concrete proto.Message. +func (w wrappedTx) decodeAny(anyPb *anypb.Any) (proto.Message, error) { + name := anyPb.GetTypeUrl() + if i := strings.LastIndexByte(name, '/'); i >= 0 { + name = name[i+len("/"):] + } + typ := proto.MessageType(name) + if typ == nil { + return nil, fmt.Errorf("unknown type: %s", name) + } + v1 := reflect.New(typ.Elem()).Interface().(proto.Message) + err := w.cdc.Unmarshal(anyPb.GetValue(), v1) + if err != nil { + return nil, err + } + return v1, nil +} diff --git a/crypto/keyring/autocli.go b/crypto/keyring/autocli.go index 0dd91ff60a43..26f39b3e4362 100644 --- a/crypto/keyring/autocli.go +++ b/crypto/keyring/autocli.go @@ -2,6 +2,7 @@ package keyring import ( signingv1beta1 "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + "cosmossdk.io/core/address" cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" @@ -21,15 +22,22 @@ type autoCLIKeyring interface { // Sign signs the given bytes with the key with the given name. Sign(name string, msg []byte, signMode signingv1beta1.SignMode) ([]byte, error) + + // KeyType returns the type of the key. + KeyType(name string) (uint, error) + + // KeyInfo given a key name or address returns key name, key address and key type. + KeyInfo(name string) (string, string, uint, error) } // NewAutoCLIKeyring wraps the SDK keyring and make it compatible with the AutoCLI keyring interfaces. -func NewAutoCLIKeyring(kr Keyring) (autoCLIKeyring, error) { - return &autoCLIKeyringAdapter{kr}, nil +func NewAutoCLIKeyring(kr Keyring, ac address.Codec) (autoCLIKeyring, error) { + return &autoCLIKeyringAdapter{kr, ac}, nil } type autoCLIKeyringAdapter struct { Keyring + ac address.Codec } func (a *autoCLIKeyringAdapter) List() ([]string, error) { @@ -84,3 +92,40 @@ func (a *autoCLIKeyringAdapter) Sign(name string, msg []byte, signMode signingv1 signBytes, _, err := a.Keyring.Sign(record.Name, msg, sdkSignMode) return signBytes, err } + +func (a *autoCLIKeyringAdapter) KeyType(name string) (uint, error) { + record, err := a.Keyring.Key(name) + if err != nil { + return 0, err + } + + return uint(record.GetType()), nil +} + +func (a *autoCLIKeyringAdapter) KeyInfo(nameOrAddr string) (string, string, uint, error) { + addr, err := a.ac.StringToBytes(nameOrAddr) + if err != nil { + // If conversion fails, it's likely a name, not an address + record, err := a.Keyring.Key(nameOrAddr) + if err != nil { + return "", "", 0, err + } + addr, err = record.GetAddress() + if err != nil { + return "", "", 0, err + } + addrStr, err := a.ac.BytesToString(addr) + if err != nil { + return "", "", 0, err + } + return record.Name, addrStr, uint(record.GetType()), nil + } + + // If conversion succeeds, it's an address, get the key info by address + record, err := a.Keyring.KeyByAddress(addr) + if err != nil { + return "", "", 0, err + } + + return record.Name, nameOrAddr, uint(record.GetType()), nil +}