diff --git a/CHANGELOG.md b/CHANGELOG.md index 993ed6798942..97d3304298d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ * [\#9533](https://github.com/cosmos/cosmos-sdk/pull/9533) Added a new gRPC method, `DenomOwners`, in `x/bank` to query for all account holders of a specific denomination. * (bank) [\#9618](https://github.com/cosmos/cosmos-sdk/pull/9618) Update bank.Metadata: add URI and URIHash attributes. +* [\#9750](https://github.com/cosmos/cosmos-sdk/pull/9750) Emit events for tx signature and sequence, so clients can now query txs by signature (`tx.signature=''`) or by address and sequence combo (`tx.acc_seq='/'`). ### API Breaking Changes diff --git a/types/events.go b/types/events.go index 5a2bf3af4b08..27a0017635af 100644 --- a/types/events.go +++ b/types/events.go @@ -223,6 +223,11 @@ func toBytes(i interface{}) []byte { // Common event types and attribute keys var ( + EventTypeTx = "tx" + + AttributeKeyAccountSequence = "acc_seq" + AttributeKeySignature = "signature" + EventTypeMessage = "message" AttributeKeyAction = "action" diff --git a/x/auth/ante/sigverify.go b/x/auth/ante/sigverify.go index 49de89b220fa..638e837aa3f4 100644 --- a/x/auth/ante/sigverify.go +++ b/x/auth/ante/sigverify.go @@ -2,6 +2,7 @@ package ante import ( "bytes" + "encoding/base64" "encoding/hex" "fmt" @@ -94,6 +95,34 @@ func (spkd SetPubKeyDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate b spkd.ak.SetAccount(ctx, acc) } + // Also emit the following events, so that txs can be indexed by these + // indices: + // - signature (via `tx.signature=''`), + // - concat(address,"/",sequence) (via `tx.acc_seq='cosmos1abc...def/42'`). + sigs, err := sigTx.GetSignaturesV2() + if err != nil { + return ctx, err + } + + var events sdk.Events + for i, sig := range sigs { + events = append(events, sdk.NewEvent(sdk.EventTypeTx, + sdk.NewAttribute(sdk.AttributeKeyAccountSequence, fmt.Sprintf("%s/%d", signers[i], sig.Sequence)), + )) + + sigBzs, err := signatureDataToBz(sig.Data) + if err != nil { + return ctx, err + } + for _, sigBz := range sigBzs { + events = append(events, sdk.NewEvent(sdk.EventTypeTx, + sdk.NewAttribute(sdk.AttributeKeySignature, base64.StdEncoding.EncodeToString(sigBz)), + )) + } + } + + ctx.EventManager().EmitEvents(events) + return next(ctx, tx, simulate) } @@ -447,3 +476,42 @@ func CountSubKeys(pub cryptotypes.PubKey) int { return numKeys } + +// signatureDataToBz converts a SignatureData into raw bytes signature. +// For SingleSignatureData, it returns the signature raw bytes. +// For MultiSignatureData, it returns an array of all individual signatures, +// as well as the aggregated signature. +func signatureDataToBz(data signing.SignatureData) ([][]byte, error) { + if data == nil { + return nil, fmt.Errorf("got empty SignatureData") + } + + switch data := data.(type) { + case *signing.SingleSignatureData: + return [][]byte{data.Signature}, nil + case *signing.MultiSignatureData: + sigs := [][]byte{} + var err error + + for _, d := range data.Signatures { + nestedSigs, err := signatureDataToBz(d) + if err != nil { + return nil, err + } + sigs = append(sigs, nestedSigs...) + } + + multisig := cryptotypes.MultiSignature{ + Signatures: sigs, + } + aggregatedSig, err := multisig.Marshal() + if err != nil { + return nil, err + } + sigs = append(sigs, aggregatedSig) + + return sigs, nil + default: + return nil, sdkerrors.ErrInvalidType.Wrapf("unexpected signature data type %T", data) + } +} diff --git a/x/auth/client/cli/query.go b/x/auth/client/cli/query.go index 6f378e8f8b56..ce4f6e95921d 100644 --- a/x/auth/client/cli/query.go +++ b/x/auth/client/cli/query.go @@ -10,6 +10,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/client/flags" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/errors" "github.com/cosmos/cosmos-sdk/types/query" "github.com/cosmos/cosmos-sdk/version" authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" @@ -18,6 +19,11 @@ import ( const ( flagEvents = "events" + flagType = "type" + + typeHash = "hash" + typeAccSeq = "acc_seq" + typeSig = "signature" eventFormat = "{eventType}.{eventAttribute}={value}" ) @@ -210,28 +216,110 @@ $ %s query txs --%s 'message.sender=cosmos1...&message.action=withdraw_delegator // QueryTxCmd implements the default command for a tx query. func QueryTxCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "tx [hash]", - Short: "Query for a transaction by hash in a committed block", - Args: cobra.ExactArgs(1), + Use: "tx --type=[hash|acc_seq|signature] [hash|acc_seq|signature]", + Short: "Query for a transaction by hash, addr++seq combination or signature in a committed block", + Long: strings.TrimSpace(fmt.Sprintf(` +Example: +$ %s query tx +$ %s query tx --%s=%s : +$ %s query tx --%s=%s +`, + version.AppName, + version.AppName, flagType, typeAccSeq, + version.AppName, flagType, typeSig)), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { clientCtx, err := client.GetClientQueryContext(cmd) if err != nil { return err } - output, err := authtx.QueryTx(clientCtx, args[0]) - if err != nil { - return err - } - if output.Empty() { - return fmt.Errorf("no transaction found with hash %s", args[0]) - } + typ, _ := cmd.Flags().GetString(flagType) + + switch typ { + case typeHash: + { + if args[0] == "" { + return fmt.Errorf("argument should be a tx hash") + } + + // If hash is given, then query the tx by hash. + output, err := authtx.QueryTx(clientCtx, args[0]) + if err != nil { + return err + } - return clientCtx.PrintProto(output) + if output.Empty() { + return fmt.Errorf("no transaction found with hash %s", args[0]) + } + + return clientCtx.PrintProto(output) + } + case typeSig: + { + sigParts, err := parseSigArgs(args) + if err != nil { + return err + } + tmEvents := make([]string, len(sigParts)) + for i, sig := range sigParts { + tmEvents[i] = fmt.Sprintf("%s.%s='%s'", sdk.EventTypeTx, sdk.AttributeKeySignature, sig) + } + + txs, err := authtx.QueryTxsByEvents(clientCtx, tmEvents, query.DefaultPage, query.DefaultLimit, "") + if err != nil { + return err + } + if len(txs.Txs) == 0 { + return fmt.Errorf("found no txs matching given signatures") + } + if len(txs.Txs) > 1 { + // This case means there's a bug somewhere else in the code. Should not happen. + return errors.ErrLogic.Wrapf("found %d txs matching given signatures", len(txs.Txs)) + } + + return clientCtx.PrintProto(txs.Txs[0]) + } + case typeAccSeq: + { + if args[0] == "" { + return fmt.Errorf("`acc_seq` type takes an argument '/'") + } + + tmEvents := []string{ + fmt.Sprintf("%s.%s='%s'", sdk.EventTypeTx, sdk.AttributeKeyAccountSequence, args[0]), + } + txs, err := authtx.QueryTxsByEvents(clientCtx, tmEvents, query.DefaultPage, query.DefaultLimit, "") + if err != nil { + return err + } + if len(txs.Txs) == 0 { + return fmt.Errorf("found no txs matching given address and sequence combination") + } + if len(txs.Txs) > 1 { + // This case means there's a bug somewhere else in the code. Should not happen. + return fmt.Errorf("found %d txs matching given address and sequence combination", len(txs.Txs)) + } + + return clientCtx.PrintProto(txs.Txs[0]) + } + default: + return fmt.Errorf("unknown --%s value %s", flagType, typ) + } }, } flags.AddQueryFlagsToCmd(cmd) + cmd.Flags().String(flagType, typeHash, fmt.Sprintf("The type to be used when querying tx, can be one of \"%s\", \"%s\", \"%s\"", typeHash, typeAccSeq, typeSig)) return cmd } + +// parseSigArgs parses comma-separated signatures from the CLI arguments. +func parseSigArgs(args []string) ([]string, error) { + if len(args) != 1 || args[0] == "" { + return nil, fmt.Errorf("argument should be comma-separated signatures") + } + + return strings.Split(args[0], ","), nil +} diff --git a/x/auth/client/cli/query_test.go b/x/auth/client/cli/query_test.go new file mode 100644 index 000000000000..0168d3008179 --- /dev/null +++ b/x/auth/client/cli/query_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseSigs(t *testing.T) { + cases := []struct { + name string + args []string + expErr bool + expNumSigs int + }{ + {"no args", []string{}, true, 0}, + {"empty args", []string{""}, true, 0}, + {"too many args", []string{"foo", "bar"}, true, 0}, + {"1 sig", []string{"foo"}, false, 1}, + {"3 sigs", []string{"foo,bar,baz"}, false, 3}, + } + + for _, tc := range cases { + sigs, err := parseSigArgs(tc.args) + if tc.expErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expNumSigs, len(sigs)) + } + } +} diff --git a/x/auth/client/testutil/suite.go b/x/auth/client/testutil/suite.go index 06e47e0164c3..666542e30e1a 100644 --- a/x/auth/client/testutil/suite.go +++ b/x/auth/client/testutil/suite.go @@ -2,6 +2,7 @@ package testutil import ( "context" + "encoding/base64" "encoding/json" "fmt" "io/ioutil" @@ -245,7 +246,7 @@ func checkSignatures(require *require.Assertions, txCfg client.TxConfig, output } } -func (s *IntegrationTestSuite) TestCLIQueryTxCmd() { +func (s *IntegrationTestSuite) TestCLIQueryTxCmdByHash() { val := s.network.Validators[0] account2, err := val.ClientCtx.Keyring.Key("newAccount2") @@ -269,23 +270,26 @@ func (s *IntegrationTestSuite) TestCLIQueryTxCmd() { expectErr bool rawLogContains string }{ + { + "not enough args", + []string{}, + true, "", + }, { "with invalid hash", []string{"somethinginvalid", fmt.Sprintf("--%s=json", tmcli.OutputFlag)}, - true, - "", + true, "", }, { "with valid and not existing hash", []string{"C7E7D3A86A17AB3A321172239F3B61357937AF0F25D9FA4D2F4DCCAD9B0D7747", fmt.Sprintf("--%s=json", tmcli.OutputFlag)}, - true, - "", + true, "", }, { "happy case", []string{txRes.TxHash, fmt.Sprintf("--%s=json", tmcli.OutputFlag)}, false, - "/cosmos.bank.v1beta1.MsgSend", + sdk.MsgTypeURL(&banktypes.MsgSend{}), }, } @@ -310,6 +314,120 @@ func (s *IntegrationTestSuite) TestCLIQueryTxCmd() { } } +func (s *IntegrationTestSuite) TestCLIQueryTxCmdByEvents() { + val := s.network.Validators[0] + + account2, err := val.ClientCtx.Keyring.Key("newAccount2") + s.Require().NoError(err) + + sendTokens := sdk.NewInt64Coin(s.cfg.BondDenom, 10) + + // Send coins. + out, err := s.createBankMsg( + val, account2.GetAddress(), + sdk.NewCoins(sendTokens), + ) + s.Require().NoError(err) + var txRes sdk.TxResponse + s.Require().NoError(val.ClientCtx.Codec.UnmarshalJSON(out.Bytes(), &txRes)) + s.Require().NoError(s.network.WaitForNextBlock()) + + // Query the tx by hash to get the inner tx. + out, err = clitestutil.ExecTestCLICmd(val.ClientCtx, authcli.QueryTxCmd(), []string{txRes.TxHash, fmt.Sprintf("--%s=json", tmcli.OutputFlag)}) + s.Require().NoError(err) + s.Require().NoError(val.ClientCtx.Codec.UnmarshalJSON(out.Bytes(), &txRes)) + protoTx := txRes.GetTx().(*tx.Tx) + + testCases := []struct { + name string + args []string + expectErr bool + expectErrStr string + }{ + { + "invalid --type", + []string{ + fmt.Sprintf("--type=%s", "foo"), + "bar", + fmt.Sprintf("--%s=json", tmcli.OutputFlag), + }, + true, "unknown --type value foo", + }, + { + "--type=acc_seq with no addr+seq", + []string{ + "--type=acc_seq", + "", + fmt.Sprintf("--%s=json", tmcli.OutputFlag), + }, + true, "`acc_seq` type takes an argument '/'", + }, + { + "non-existing addr+seq combo", + []string{ + "--type=acc_seq", + "foobar", + fmt.Sprintf("--%s=json", tmcli.OutputFlag), + }, + true, "found no txs matching given address and sequence combination", + }, + { + "addr+seq happy case", + []string{ + "--type=acc_seq", + fmt.Sprintf("%s/%d", val.Address, protoTx.AuthInfo.SignerInfos[0].Sequence), + fmt.Sprintf("--%s=json", tmcli.OutputFlag), + }, + false, "", + }, + { + "--type=signature with no signature", + []string{ + "--type=signature", + "", + fmt.Sprintf("--%s=json", tmcli.OutputFlag), + }, + true, "argument should be comma-separated signatures", + }, + { + "non-existing signatures", + []string{ + "--type=signature", + "foo", + fmt.Sprintf("--%s=json", tmcli.OutputFlag), + }, + true, "found no txs matching given signatures", + }, + { + "with --signatures happy case", + []string{ + "--type=signature", + base64.StdEncoding.EncodeToString(protoTx.Signatures[0]), + fmt.Sprintf("--%s=json", tmcli.OutputFlag), + }, + false, "", + }, + } + + for _, tc := range testCases { + tc := tc + s.Run(tc.name, func() { + cmd := authcli.QueryTxCmd() + clientCtx := val.ClientCtx + + out, err := clitestutil.ExecTestCLICmd(clientCtx, cmd, tc.args) + if tc.expectErr { + s.Require().Error(err) + s.Require().Contains(err.Error(), tc.expectErrStr) + } else { + var result sdk.TxResponse + s.Require().NoError(val.ClientCtx.Codec.UnmarshalJSON(out.Bytes(), &result)) + s.Require().NotNil(result.Height) + } + }) + } +} + func (s *IntegrationTestSuite) TestCLISendGenerateSignAndBroadcast() { val1 := s.network.Validators[0] @@ -681,7 +799,7 @@ func (s *IntegrationTestSuite) TestCLIMultisign() { sign1File := testutil.WriteToNewTempFile(s.T(), account1Signature.String()) - // Sign with account1 + // Sign with account2 account2Signature, err := TxSignExec(val1.ClientCtx, account2.GetAddress(), multiGeneratedTxFile.Name(), "--multisig", multisigInfo.GetAddress().String()) s.Require().NoError(err)