diff --git a/gno.land/pkg/gnoclient/README.md b/gno.land/pkg/gnoclient/README.md new file mode 100644 index 00000000000..390559fb142 --- /dev/null +++ b/gno.land/pkg/gnoclient/README.md @@ -0,0 +1,22 @@ +# Gno.land Go Client + +The Gno.land Go client is a dedicated library for interacting seamlessly with the Gno.land RPC API. +This library simplifies the process of querying or sending transactions to the Gno.land RPC API and interpreting the responses. + +## Installation + +Integrate this library into your Go project with the following command: + + go get github.com/gnolang/gno/gno.land/pkg/gnoclient + +## Development Plan + +The roadmap for the Gno.land Go client includes: + +- **Initial Development:** Kickstart the development specifically for Gno.land. Subsequently, transition the generic functionalities to other modules like `tm2`, `gnovm`, `gnosdk`. +- **Integration:** Begin incorporating this library within various components such as `gno.land/cmd/*` and other external clients, including `gnoblog-client`, the Discord community faucet bot, and [GnoMobile](https://github.com/gnolang/gnomobile). +- **Enhancements:** Once the generic client establishes a robust foundation, we aim to utilize code generation for contracts. This will streamline the creation of type-safe, contract-specific clients. + +## Usage + +TODO: Documentation for usage is currently in development and will be available soon. diff --git a/gno.land/pkg/gnoclient/client.go b/gno.land/pkg/gnoclient/client.go new file mode 100644 index 00000000000..2c43a5fa01d --- /dev/null +++ b/gno.land/pkg/gnoclient/client.go @@ -0,0 +1,28 @@ +package gnoclient + +import ( + rpcclient "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/gnolang/gno/tm2/pkg/errors" +) + +// Client provides an interface for interacting with the blockchain. +type Client struct { + Signer Signer // Signer for transaction authentication + RPCClient rpcclient.Client // RPC client for blockchain communication +} + +// validateSigner checks that the signer is correctly configured. +func (c Client) validateSigner() error { + if c.Signer == nil { + return errors.New("missing Signer") + } + return nil +} + +// validateRPCClient checks that the RPCClient is correctly configured. +func (c Client) validateRPCClient() error { + if c.RPCClient == nil { + return errors.New("missing RPCClient") + } + return nil +} diff --git a/gno.land/pkg/gnoclient/client_queries.go b/gno.land/pkg/gnoclient/client_queries.go new file mode 100644 index 00000000000..ba63c0d543e --- /dev/null +++ b/gno.land/pkg/gnoclient/client_queries.go @@ -0,0 +1,124 @@ +package gnoclient + +import ( + "fmt" + + "github.com/gnolang/gno/tm2/pkg/amino" + rpcclient "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + ctypes "github.com/gnolang/gno/tm2/pkg/bft/rpc/core/types" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/errors" + "github.com/gnolang/gno/tm2/pkg/std" +) + +// QueryCfg contains configuration options for performing queries. +type QueryCfg struct { + Path string // Query path + Data []byte // Query data + rpcclient.ABCIQueryOptions // ABCI query options +} + +// Query performs a generic query on the blockchain. +func (c Client) Query(cfg QueryCfg) (*ctypes.ResultABCIQuery, error) { + if err := c.validateRPCClient(); err != nil { + return nil, err + } + qres, err := c.RPCClient.ABCIQueryWithOptions(cfg.Path, cfg.Data, cfg.ABCIQueryOptions) + if err != nil { + return nil, errors.Wrap(err, "query error") + } + + if qres.Response.Error != nil { + return qres, errors.Wrap(qres.Response.Error, "deliver transaction failed: log:%s", qres.Response.Log) + } + + return qres, nil +} + +// QueryAccount retrieves account information for a given address. +func (c Client) QueryAccount(addr crypto.Address) (*std.BaseAccount, *ctypes.ResultABCIQuery, error) { + if err := c.validateRPCClient(); err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("auth/accounts/%s", crypto.AddressToBech32(addr)) + data := []byte{} + + qres, err := c.RPCClient.ABCIQuery(path, data) + if err != nil { + return nil, nil, errors.Wrap(err, "query account") + } + if qres.Response.Data == nil || len(qres.Response.Data) == 0 || string(qres.Response.Data) == "null" { + return nil, nil, std.ErrUnknownAddress("unknown address: " + crypto.AddressToBech32(addr)) + } + + var qret struct{ BaseAccount std.BaseAccount } + err = amino.UnmarshalJSON(qres.Response.Data, &qret) + if err != nil { + return nil, nil, err + } + + return &qret.BaseAccount, qres, nil +} + +func (c Client) QueryAppVersion() (string, *ctypes.ResultABCIQuery, error) { + if err := c.validateRPCClient(); err != nil { + return "", nil, err + } + + path := ".app/version" + data := []byte{} + + qres, err := c.RPCClient.ABCIQuery(path, data) + if err != nil { + return "", nil, errors.Wrap(err, "query app version") + } + + version := string(qres.Response.Value) + return version, qres, nil +} + +// Render calls the Render function for pkgPath with optional args. The pkgPath should +// include the prefix like "gno.land/". This is similar to using a browser URL +// /: where doesn't have the prefix like "gno.land/". +func (c Client) Render(pkgPath string, args string) (string, *ctypes.ResultABCIQuery, error) { + if err := c.validateRPCClient(); err != nil { + return "", nil, err + } + + path := "vm/qrender" + data := []byte(fmt.Sprintf("%s\n%s", pkgPath, args)) + + qres, err := c.RPCClient.ABCIQuery(path, data) + if err != nil { + return "", nil, errors.Wrap(err, "query render") + } + if qres.Response.Error != nil { + return "", nil, errors.Wrap(qres.Response.Error, "Render failed: log:%s", qres.Response.Log) + } + + return string(qres.Response.Data), qres, nil +} + +// QEval evaluates the given expression with the realm code at pkgPath. The pkgPath should +// include the prefix like "gno.land/". The expression is usually a function call like +// "GetBoardIDFromName(\"testboard\")". The return value is a typed expression like +// "(1 gno.land/r/demo/boards.BoardID)\n(true bool)". +func (c Client) QEval(pkgPath string, expression string) (string, *ctypes.ResultABCIQuery, error) { + if err := c.validateRPCClient(); err != nil { + return "", nil, err + } + + path := "vm/qeval" + data := []byte(fmt.Sprintf("%s\n%s", pkgPath, expression)) + + qres, err := c.RPCClient.ABCIQuery(path, data) + if err != nil { + return "", nil, errors.Wrap(err, "query qeval") + } + if qres.Response.Error != nil { + return "", nil, errors.Wrap(qres.Response.Error, "QEval failed: log:%s", qres.Response.Log) + } + + return string(qres.Response.Data), qres, nil +} diff --git a/gno.land/pkg/gnoclient/client_test.go b/gno.land/pkg/gnoclient/client_test.go new file mode 100644 index 00000000000..418a95aa997 --- /dev/null +++ b/gno.land/pkg/gnoclient/client_test.go @@ -0,0 +1,52 @@ +package gnoclient + +import ( + "testing" + + "github.com/gnolang/gno/gno.land/pkg/integration" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + rpcclient "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/gnolang/gno/tm2/pkg/crypto/keys" + "github.com/gnolang/gno/tm2/pkg/log" + "github.com/jaekwon/testify/require" +) + +func newInMemorySigner(t *testing.T, chainid string) *SignerFromKeybase { + t.Helper() + + mmeonic := integration.DefaultAccount_Seed + name := integration.DefaultAccount_Name + + kb := keys.NewInMemory() + _, err := kb.CreateAccount(name, mmeonic, "", "", uint32(0), uint32(0)) + require.NoError(t, err) + + return &SignerFromKeybase{ + Keybase: kb, // Stores keys in memory or on disk + Account: name, // Account name or bech32 format + Password: "", // Password for encryption + ChainID: chainid, // Chain ID for transaction signing + } +} + +func TestClient_Request(t *testing.T) { + config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNopLogger(), config) + defer node.Stop() + + signer := newInMemorySigner(t, config.TMConfig.ChainID()) + + client := Client{ + Signer: signer, + RPCClient: rpcclient.NewHTTP(remoteAddr, "/websocket"), + } + + data, res, err := client.Render("gno.land/r/demo/boards", "") + require.NoError(t, err) + require.NotEmpty(t, data) + + require.NotNil(t, res) + require.NotEmpty(t, res.Response.Data) + + // XXX: need more test +} diff --git a/gno.land/pkg/gnoclient/client_txs.go b/gno.land/pkg/gnoclient/client_txs.go new file mode 100644 index 00000000000..ab475154fad --- /dev/null +++ b/gno.land/pkg/gnoclient/client_txs.go @@ -0,0 +1,127 @@ +package gnoclient + +import ( + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/tm2/pkg/amino" + ctypes "github.com/gnolang/gno/tm2/pkg/bft/rpc/core/types" + "github.com/gnolang/gno/tm2/pkg/errors" + "github.com/gnolang/gno/tm2/pkg/std" +) + +// CallCfg contains configuration options for executing a contract call. +type CallCfg struct { + PkgPath string // Package path + FuncName string // Function name + Args []string // Function arguments + GasFee string // Gas fee + GasWanted int64 // Gas wanted + Send string // Send amount + AccountNumber uint64 // Account number + SequenceNumber uint64 // Sequence number + Memo string // Memo +} + +// Call executes a contract call on the blockchain. +func (c *Client) Call(cfg CallCfg) (*ctypes.ResultBroadcastTxCommit, error) { + // Validate required client fields. + if err := c.validateSigner(); err != nil { + return nil, errors.Wrap(err, "validate signer") + } + if err := c.validateRPCClient(); err != nil { + return nil, errors.Wrap(err, "validate RPC client") + } + + pkgPath := cfg.PkgPath + funcName := cfg.FuncName + args := cfg.Args + gasWanted := cfg.GasWanted + gasFee := cfg.GasFee + send := cfg.Send + sequenceNumber := cfg.SequenceNumber + accountNumber := cfg.AccountNumber + memo := cfg.Memo + + // Validate config. + if pkgPath == "" { + return nil, errors.New("missing PkgPath") + } + if funcName == "" { + return nil, errors.New("missing FuncName") + } + + // Parse send amount. + sendCoins, err := std.ParseCoins(send) + if err != nil { + return nil, errors.Wrap(err, "parsing send coins") + } + + // Parse gas wanted & fee. + gasFeeCoins, err := std.ParseCoin(gasFee) + if err != nil { + return nil, errors.Wrap(err, "parsing gas fee coin") + } + + caller := c.Signer.Info().GetAddress() + + // Construct message & transaction and marshal. + msg := vm.MsgCall{ + Caller: caller, + Send: sendCoins, + PkgPath: pkgPath, + Func: funcName, + Args: args, + } + tx := std.Tx{ + Msgs: []std.Msg{msg}, + Fee: std.NewFee(gasWanted, gasFeeCoins), + Signatures: nil, + Memo: memo, + } + + return c.signAndBroadcastTxCommit(tx, accountNumber, sequenceNumber) +} + +// signAndBroadcastTxCommit signs a transaction and broadcasts it, returning the result. +func (c Client) signAndBroadcastTxCommit(tx std.Tx, accountNumber, sequenceNumber uint64) (*ctypes.ResultBroadcastTxCommit, error) { + caller := c.Signer.Info().GetAddress() + + if sequenceNumber == 0 || accountNumber == 0 { + account, _, err := c.QueryAccount(caller) + if err != nil { + return nil, errors.Wrap(err, "query account") + } + accountNumber = account.AccountNumber + sequenceNumber = account.Sequence + } + + signCfg := SignCfg{ + UnsignedTX: tx, + SequenceNumber: sequenceNumber, + AccountNumber: accountNumber, + } + signedTx, err := c.Signer.Sign(signCfg) + if err != nil { + return nil, errors.Wrap(err, "sign") + } + + bz, err := amino.Marshal(signedTx) + if err != nil { + return nil, errors.Wrap(err, "marshaling tx binary bytes") + } + + bres, err := c.RPCClient.BroadcastTxCommit(bz) + if err != nil { + return nil, errors.Wrap(err, "broadcasting bytes") + } + + if bres.CheckTx.IsErr() { + return bres, errors.Wrap(bres.CheckTx.Error, "check transaction failed: log:%s", bres.CheckTx.Log) + } + if bres.DeliverTx.IsErr() { + return bres, errors.Wrap(bres.DeliverTx.Error, "deliver transaction failed: log:%s", bres.DeliverTx.Log) + } + + return bres, nil +} + +// TODO: Add more functionality, examples, and unit tests. diff --git a/gno.land/pkg/gnoclient/example_test.go b/gno.land/pkg/gnoclient/example_test.go new file mode 100644 index 00000000000..a1be4481d2b --- /dev/null +++ b/gno.land/pkg/gnoclient/example_test.go @@ -0,0 +1,63 @@ +package gnoclient_test + +import ( + "fmt" + + "github.com/gnolang/gno/gno.land/pkg/gnoclient" + rpcclient "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/gnolang/gno/tm2/pkg/crypto/keys" +) + +// Example_withDisk demonstrates how to initialize a gnoclient with a keybase sourced from a directory. +func Example_withDisk() { + kb, _ := keys.NewKeyBaseFromDir("/path/to/dir") + signer := gnoclient.SignerFromKeybase{ + Keybase: kb, + Account: "mykey", + Password: "secure", + } + + remote := "127.0.0.1:26657" + rpcClient := rpcclient.NewHTTP(remote, "/websocket") + + client := gnoclient.Client{ + Signer: signer, + RPCClient: rpcClient, + } + _ = client +} + +// Example_withInMemCrypto demonstrates how to initialize a gnoclient with an in-memory keybase using BIP39 mnemonics. +func Example_withInMemCrypto() { + mnemo := "index brass unknown lecture autumn provide royal shrimp elegant wink now zebra discover swarm act ill you bullet entire outdoor tilt usage gap multiply" + bip39Passphrase := "" + account := uint32(0) + index := uint32(0) + signer, _ := gnoclient.SignerFromBip39(mnemo, bip39Passphrase, account, index) + + remote := "127.0.0.1:26657" + rpcClient := rpcclient.NewHTTP(remote, "/websocket") + + client := gnoclient.Client{ + Signer: signer, + RPCClient: rpcClient, + } + _ = client + fmt.Println("Hello") + // Output: + // Hello +} + +// Example_readOnly demonstrates how to initialize a read-only gnoclient, which can only query. +func Example_readOnly() { + remote := "127.0.0.1:26657" + rpcClient := rpcclient.NewHTTP(remote, "/websocket") + + client := gnoclient.Client{ + RPCClient: rpcClient, + } + _ = client + fmt.Println("Hello") + // Output: + // Hello +} diff --git a/gno.land/pkg/gnoclient/signer.go b/gno.land/pkg/gnoclient/signer.go new file mode 100644 index 00000000000..5eba124f2a8 --- /dev/null +++ b/gno.land/pkg/gnoclient/signer.go @@ -0,0 +1,144 @@ +package gnoclient + +import ( + "fmt" + + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/tm2/pkg/crypto/keys" + "github.com/gnolang/gno/tm2/pkg/errors" + "github.com/gnolang/gno/tm2/pkg/std" +) + +// Signer provides an interface for signing transactions. +type Signer interface { + Sign(SignCfg) (*std.Tx, error) // Signs a transaction and returns a signed tx ready for broadcasting. + Info() keys.Info // Returns key information, including the address. + Validate() error // Checks whether the signer is properly configured. +} + +// SignerFromKeybase represents a signer created from a Keybase. +type SignerFromKeybase struct { + Keybase keys.Keybase // Stores keys in memory or on disk + Account string // Account name or bech32 format + Password string // Password for encryption + ChainID string // Chain ID for transaction signing +} + +func (s SignerFromKeybase) Validate() error { + if s.ChainID == "" { + return errors.New("missing ChainID") + } + + _, err := s.Keybase.GetByNameOrAddress(s.Account) + if err != nil { + return err + } + + // To verify if the password unlocks the account, sign a blank transaction. + msg := vm.MsgCall{ + Caller: s.Info().GetAddress(), + } + signCfg := SignCfg{ + UnsignedTX: std.Tx{ + Msgs: []std.Msg{msg}, + Fee: std.NewFee(0, std.NewCoin("ugnot", 1000000)), + }, + } + if _, err = s.Sign(signCfg); err != nil { + return err + } + + return nil +} + +func (s SignerFromKeybase) Info() keys.Info { + info, err := s.Keybase.GetByNameOrAddress(s.Account) + if err != nil { + panic("should not happen") + } + return info +} + +// Sign implements the Signer interface for SignerFromKeybase. +type SignCfg struct { + UnsignedTX std.Tx + SequenceNumber uint64 + AccountNumber uint64 +} + +func (s SignerFromKeybase) Sign(cfg SignCfg) (*std.Tx, error) { + tx := cfg.UnsignedTX + chainID := s.ChainID + accountNumber := cfg.AccountNumber + sequenceNumber := cfg.SequenceNumber + account := s.Account + password := s.Password + + // Initialize tx signatures. + signers := tx.GetSigners() + if tx.Signatures == nil { + for range signers { + tx.Signatures = append(tx.Signatures, std.Signature{ + PubKey: nil, // Zero signature + Signature: nil, // Zero signature + }) + } + } + + // Validate the transaction to sign. + err := tx.ValidateBasic() + if err != nil { + return nil, err + } + + // Derive sign doc bytes. + signbz := tx.GetSignBytes(chainID, accountNumber, sequenceNumber) + + sig, pub, err := s.Keybase.Sign(account, password, signbz) + if err != nil { + return nil, err + } + addr := pub.Address() + found := false + for i := range tx.Signatures { + if signers[i] == addr { + found = true + tx.Signatures[i] = std.Signature{ + PubKey: pub, + Signature: sig, + } + } + } + + if !found { + return nil, fmt.Errorf("address %v (%s) not in signer set", addr, account) + } + + return &tx, nil +} + +// Ensure SignerFromKeybase implements the Signer interface. +var _ Signer = (*SignerFromKeybase)(nil) + +// SignerFromBip39 creates an in-memory keybase with a single default account. +// This can be useful in scenarios where storing private keys in the filesystem isn't feasible. +// +// Warning: Using keys.NewKeyBaseFromDir is recommended where possible, as it is more secure. +func SignerFromBip39(mnemonic string, passphrase string, account uint32, index uint32) (Signer, error) { + kb := keys.NewInMemory() + name := "default" + password := "" // Password isn't needed for in-memory storage + + _, err := kb.CreateAccount(name, mnemonic, passphrase, password, account, index) + if err != nil { + return nil, err + } + + signer := SignerFromKeybase{ + Keybase: kb, + Account: name, + Password: password, + } + + return &signer, nil +} diff --git a/gno.land/pkg/gnoclient/signer_test.go b/gno.land/pkg/gnoclient/signer_test.go new file mode 100644 index 00000000000..3b4cbb757ad --- /dev/null +++ b/gno.land/pkg/gnoclient/signer_test.go @@ -0,0 +1 @@ +package gnoclient diff --git a/tm2/pkg/crypto/keys/keybase.go b/tm2/pkg/crypto/keys/keybase.go index 31b0012c433..5b69f36a22d 100644 --- a/tm2/pkg/crypto/keys/keybase.go +++ b/tm2/pkg/crypto/keys/keybase.go @@ -42,6 +42,9 @@ const ( French // Italian is currently not supported. Italian +) + +const ( addressSuffix = "address" infoSuffix = "info" )