Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Ledger Client #37

Merged
merged 4 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/test-ledger.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
on:
push:
paths:
- clients/ledger/**
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: '1.22.1'
- run: make test-ledger
2 changes: 1 addition & 1 deletion .github/workflows/test-registry.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
on:
push:
paths:
- registry/**
- clients/registry/**
jobs:
test:
runs-on: ubuntu-latest
Expand Down
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ test:
go test -v -cover ./...

test-registry:
REGISTRY_TEST_ENABLE=true go test -v -cover ./registry/...
REGISTRY_TEST_ENABLE=true go test -v -cover ./clients/registry/...

test-ledger:
REGISTRY_TEST_ENABLE=true go test -v -cover ./clients/ledger/...

check-moc:
find ic -type f -name '*.mo' -print0 | xargs -0 $(shell dfx cache show)/moc --check
Expand All @@ -16,7 +19,8 @@ test-cover:
gen:
cd candid && go generate
cd pocketic && go generate
cd registry && go generate
cd clients/ledger && go generate
cd clients/registry && go generate

gen-ic:
go run ic/testdata/gen.go
Expand Down
120 changes: 115 additions & 5 deletions agent.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agent

import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
Expand All @@ -10,11 +11,13 @@ import (
"reflect"
"time"

"github.com/aviate-labs/agent-go/candid/idl"
"github.com/aviate-labs/agent-go/certification"
"github.com/aviate-labs/agent-go/certification/hashtree"
"github.com/aviate-labs/agent-go/identity"
"github.com/aviate-labs/agent-go/principal"
"github.com/fxamacker/cbor/v2"
"google.golang.org/protobuf/proto"
)

// DefaultConfig is the default configuration for an Agent.
Expand Down Expand Up @@ -89,9 +92,67 @@ func uint64FromBytes(raw []byte) uint64 {
}
}

type APIRequest[In, Out any] struct {
a *Agent
unmarshal func([]byte, Out) error
typ RequestType
methodName string
effectiveCanisterID principal.Principal
requestID RequestID
data []byte
}

func createAPIRequest[In, Out any](
a *Agent,
marshal func(In) ([]byte, error),
unmarshal func([]byte, Out) error,
typ RequestType,
canisterID principal.Principal,
effectiveCanisterID principal.Principal,
methodName string,
in In,
) (*APIRequest[In, Out], error) {
rawArgs, err := marshal(in)
if err != nil {
return nil, err
}
nonce, err := newNonce()
if err != nil {
return nil, err
}
requestID, data, err := a.sign(Request{
Type: typ,
Sender: a.Sender(),
CanisterID: canisterID,
MethodName: methodName,
Arguments: rawArgs,
IngressExpiry: a.expiryDate(),
Nonce: nonce,
})
if err != nil {
return nil, err
}
return &APIRequest[In, Out]{
a: a,
unmarshal: unmarshal,
typ: typ,
methodName: methodName,
effectiveCanisterID: effectiveCanisterID,
requestID: *requestID,
data: data,
}, nil
}

// WithEffectiveCanisterID sets the effective canister ID for the Call.
func (c *APIRequest[In, Out]) WithEffectiveCanisterID(canisterID principal.Principal) *APIRequest[In, Out] {
c.effectiveCanisterID = canisterID
return c
}

// Agent is a client for the Internet Computer.
type Agent struct {
client Client
ctx context.Context
identity identity.Identity
ingressExpiry time.Duration
rootKey []byte
Expand All @@ -103,7 +164,7 @@ type Agent struct {
// New returns a new Agent based on the given configuration.
func New(cfg Config) (*Agent, error) {
if cfg.IngressExpiry == 0 {
cfg.IngressExpiry = time.Minute
cfg.IngressExpiry = 5 * time.Minute
}
// By default, use the anonymous identity.
var id identity.Identity = new(identity.AnonymousIdentity)
Expand Down Expand Up @@ -139,6 +200,7 @@ func New(cfg Config) (*Agent, error) {
}
return &Agent{
client: client,
ctx: context.Background(),
identity: id,
ingressExpiry: cfg.IngressExpiry,
rootKey: rootKey,
Expand All @@ -154,6 +216,44 @@ func (a Agent) Client() *Client {
return &a.client
}

// CreateCandidAPIRequest creates a new api request to the given canister and method.
func (a *Agent) CreateCandidAPIRequest(typ RequestType, canisterID principal.Principal, methodName string, args ...any) (*CandidAPIRequest, error) {
return createAPIRequest[[]any, []any](
a,
idl.Marshal,
idl.Unmarshal,
typ,
canisterID,
effectiveCanisterID(canisterID, args),
methodName,
args,
)
}

// CreateProtoAPIRequest creates a new api request to the given canister and method.
func (a *Agent) CreateProtoAPIRequest(typ RequestType, canisterID principal.Principal, methodName string, message proto.Message) (*ProtoAPIRequest, error) {
return createAPIRequest[proto.Message, proto.Message](
a,
func(m proto.Message) ([]byte, error) {
raw, err := proto.Marshal(m)
if err != nil {
return nil, err
}
if len(raw) == 0 {
// Protobuf arg are not allowed to be empty.
return []byte{}, nil
}
return raw, nil
},
proto.Unmarshal,
typ,
canisterID,
canisterID,
methodName,
message,
)
}

// GetCanisterControllers returns the list of principals that can control the given canister.
func (a Agent) GetCanisterControllers(canisterID principal.Principal) ([]principal.Principal, error) {
resp, err := a.GetCanisterInfo(canisterID, "controllers")
Expand Down Expand Up @@ -252,7 +352,9 @@ func (a Agent) Sender() principal.Principal {
}

func (a Agent) call(ecID principal.Principal, data []byte) ([]byte, error) {
return a.client.Call(ecID, data)
ctx, cancel := context.WithTimeout(a.ctx, a.ingressExpiry)
defer cancel()
return a.client.Call(ctx, ecID, data)
}

func (a Agent) expiryDate() uint64 {
Expand Down Expand Up @@ -299,7 +401,9 @@ func (a Agent) poll(ecID principal.Principal, requestID RequestID) ([]byte, erro
}

func (a Agent) readState(ecID principal.Principal, data []byte) (map[string][]byte, error) {
resp, err := a.client.ReadState(ecID, data)
ctx, cancel := context.WithTimeout(a.ctx, a.ingressExpiry)
defer cancel()
resp, err := a.client.ReadState(ctx, ecID, data)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -336,7 +440,9 @@ func (a Agent) readStateCertificate(ecID principal.Principal, paths [][]hashtree
}

func (a Agent) readSubnetState(subnetID principal.Principal, data []byte) (map[string][]byte, error) {
resp, err := a.client.ReadSubnetState(subnetID, data)
ctx, cancel := context.WithTimeout(a.ctx, a.ingressExpiry)
defer cancel()
resp, err := a.client.ReadSubnetState(ctx, subnetID, data)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -385,12 +491,14 @@ func (a Agent) sign(request Request) (*RequestID, []byte, error) {
return &requestID, data, nil
}

type CandidAPIRequest = APIRequest[[]any, []any]

// Config is the configuration for an Agent.
type Config struct {
// Identity is the identity used by the Agent.
Identity identity.Identity
// IngressExpiry is the duration for which an ingress message is valid.
// The default is set to 1 minute.
// The default is set to 5 minutes.
IngressExpiry time.Duration
// ClientConfig is the configuration for the underlying Client.
ClientConfig *ClientConfig
Expand All @@ -405,3 +513,5 @@ type Config struct {
// DisableSignedQueryVerification disables the verification of signed queries.
DisableSignedQueryVerification bool
}

type ProtoAPIRequest = APIRequest[proto.Message, proto.Message]
111 changes: 21 additions & 90 deletions call.go
Original file line number Diff line number Diff line change
@@ -1,117 +1,48 @@
package agent

import (
"github.com/aviate-labs/agent-go/candid/idl"
"github.com/aviate-labs/agent-go/principal"
"google.golang.org/protobuf/proto"
)

// Call calls a method on a canister and unmarshals the result into the given values.
func (a Agent) Call(canisterID principal.Principal, methodName string, args []any, values []any) error {
call, err := a.CreateCall(canisterID, methodName, args...)
if err != nil {
return err
}
return call.CallAndWait(values...)
}

// CallProto calls a method on a canister and unmarshals the result into the given proto message.
func (a Agent) CallProto(canisterID principal.Principal, methodName string, in, out proto.Message) error {
payload, err := proto.Marshal(in)
if err != nil {
return err
}
requestID, data, err := a.sign(Request{
Type: RequestTypeCall,
Sender: a.Sender(),
IngressExpiry: a.expiryDate(),
CanisterID: canisterID,
MethodName: methodName,
Arguments: payload,
})
if err != nil {
return err
}
if _, err := a.call(canisterID, data); err != nil {
return err
}
raw, err := a.poll(canisterID, *requestID)
if err != nil {
return err
}
return proto.Unmarshal(raw, out)
}

// CreateCall creates a new Call to the given canister and method.
func (a *Agent) CreateCall(canisterID principal.Principal, methodName string, args ...any) (*Call, error) {
rawArgs, err := idl.Marshal(args)
if err != nil {
return nil, err
}
if len(args) == 0 {
// Default to the empty Candid argument list.
rawArgs = []byte{'D', 'I', 'D', 'L', 0, 0}
}
nonce, err := newNonce()
if err != nil {
return nil, err
}
requestID, data, err := a.sign(Request{
Type: RequestTypeCall,
Sender: a.Sender(),
CanisterID: canisterID,
MethodName: methodName,
Arguments: rawArgs,
IngressExpiry: a.expiryDate(),
Nonce: nonce,
})
if err != nil {
return nil, err
}
return &Call{
a: a,
methodName: methodName,
effectiveCanisterID: effectiveCanisterID(canisterID, args),
requestID: *requestID,
data: data,
}, nil
}

// Call is an intermediate representation of a Call to a canister.
type Call struct {
a *Agent
methodName string
effectiveCanisterID principal.Principal
requestID RequestID
data []byte
}

// Call calls a method on a canister, it does not wait for the result.
func (c Call) Call() error {
func (c APIRequest[_, _]) Call() error {
c.a.logger.Printf("[AGENT] CALL %s %s (%x)", c.effectiveCanisterID, c.methodName, c.requestID)
_, err := c.a.call(c.effectiveCanisterID, c.data)
return err
}

// CallAndWait calls a method on a canister and waits for the result.
func (c Call) CallAndWait(values ...any) error {
func (c APIRequest[_, Out]) CallAndWait(out Out) error {
if err := c.Call(); err != nil {
return err
}
return c.Wait(values...)
return c.Wait(out)
}

// Wait waits for the result of the Call and unmarshals it into the given values.
func (c Call) Wait(values ...any) error {
func (c APIRequest[_, Out]) Wait(out Out) error {
raw, err := c.a.poll(c.effectiveCanisterID, c.requestID)
if err != nil {
return err
}
return idl.Unmarshal(raw, values)
return c.unmarshal(raw, out)
}

// WithEffectiveCanisterID sets the effective canister ID for the Call.
func (c *Call) WithEffectiveCanisterID(canisterID principal.Principal) *Call {
c.effectiveCanisterID = canisterID
return c
// Call calls a method on a canister and unmarshals the result into the given values.
func (a Agent) Call(canisterID principal.Principal, methodName string, in []any, out []any) error {
call, err := a.CreateCandidAPIRequest(RequestTypeCall, canisterID, methodName, in...)
if err != nil {
return err
}
return call.CallAndWait(out)
}

// CallProto calls a method on a canister and unmarshals the result into the given proto message.
func (a Agent) CallProto(canisterID principal.Principal, methodName string, in, out proto.Message) error {
call, err := a.CreateProtoAPIRequest(RequestTypeCall, canisterID, methodName, in)
if err != nil {
return err
}
return call.CallAndWait(out)
}
Loading
Loading