Skip to content

Commit

Permalink
feat: add gno.land/pkg/gnoclient (Gno.land Go client) (#1047)
Browse files Browse the repository at this point in the history
This PR add a dedicated client 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.

- [x] initial structure and calls
- [x] unit test (depends on #1101)
- [x] add additional query methods
- [ ] add additional transaction methods (can/should be done later)

Closes #1324


<details><summary>Previous list of ideas</summary>

Note: Some content may be outdated. Please overlook for the moment, but
we'll reassess at the conclusion of this PR to identify any current
requirements.


- [ ] port
https://github.com/gnolang/gno/tree/master/tm2/pkg/crypto/keys/client
into a simple `./gno.land/pkg/gnoclient` package (Call, Send, AddPkg,
Query, Eval, Package, File)
- [ ] add unit tests so that crypto/keys/client can have less unit tests
and be more focused on CLI stuff
- [ ] create mock/testing helpers to make it more useful when writing
integration tests -> `./gno.land/cmd/gnoland/*_test.go`
- [ ] port a client, i.e., `gnofaucet`, `gnoweb`, `gnoblog`, etc; so
they use this new library instead of hardcoded manual clients
- [ ] consider having right now underlying `tm2client` and `gnovmclient`
libs that this lib will depend on, but will probably do it later and
start with a monolith library
- [ ] alternative configuration (pass existing websocket?)
- [ ] minimal go.mod to make it light to import
- [ ] open issue on other clients (gnoblog, discord faucet, gnomobile)
</details>

<details><summary>Contributors' checklist...</summary>

- [ ] Added new tests, or not needed, or not feasible
- [ ] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [ ] Updated the official documentation or not needed
- [ ] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [ ] Added references to related issues and PRs
- [ ] Provided any useful hints for running manual tests
- [ ] Added new benchmarks to [generated
graphs](https://gnoland.github.io/benchmarks), if any. More info
[here](https://github.com/gnolang/gno/blob/master/.benchmarks/README.md).
</details>

---------

Signed-off-by: Manfred Touron <[email protected]>
Signed-off-by: Jeff Thompson <[email protected]>
Signed-off-by: gfanton <[email protected]>
Co-authored-by: Jeff Thompson <[email protected]>
Co-authored-by: Guilhem Fanton <[email protected]>
  • Loading branch information
3 people authored Jan 18, 2024
1 parent 34556c4 commit 17b1303
Show file tree
Hide file tree
Showing 9 changed files with 564 additions and 0 deletions.
22 changes: 22 additions & 0 deletions gno.land/pkg/gnoclient/README.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions gno.land/pkg/gnoclient/client.go
Original file line number Diff line number Diff line change
@@ -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
}
124 changes: 124 additions & 0 deletions gno.land/pkg/gnoclient/client_queries.go
Original file line number Diff line number Diff line change
@@ -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
// <testnet>/<pkgPath>:<args> where <pkgPath> 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
}
52 changes: 52 additions & 0 deletions gno.land/pkg/gnoclient/client_test.go
Original file line number Diff line number Diff line change
@@ -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
}
127 changes: 127 additions & 0 deletions gno.land/pkg/gnoclient/client_txs.go
Original file line number Diff line number Diff line change
@@ -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.
Loading

0 comments on commit 17b1303

Please sign in to comment.