-
Notifications
You must be signed in to change notification settings - Fork 373
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add gno.land/pkg/gnoclient (Gno.land Go client) (#1047)
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
1 parent
34556c4
commit 17b1303
Showing
9 changed files
with
564 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
Oops, something went wrong.