All checks were successful
Vulncheck / Vulncheck (push) Successful in 1m19s
Pre-commit hooks / Pre-commit (push) Successful in 1m41s
Build / Build Components (push) Successful in 1m43s
Tests and linters / Run gofumpt (push) Successful in 3m38s
Tests and linters / gopls check (push) Successful in 3m41s
Tests and linters / Lint (push) Successful in 3m50s
Tests and linters / Staticcheck (push) Successful in 4m6s
Tests and linters / Tests with -race (push) Successful in 4m22s
Tests and linters / Tests (push) Successful in 4m32s
OCI image / Build container images (push) Successful in 5m0s
NNS contract hash is taken from the contract with ID=1. Because morph client is expected to work with the same chain, and because contract hash doesn't change on update, there is no need to fetch it from each new endpoint. Change-Id: Ic6dc18283789da076d6a0b3701139b97037714cc Signed-off-by: Evgenii Stratonikov <e.stratonikov@yadro.com>
593 lines
15 KiB
Go
593 lines
15 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.frostfs.info/TrueCloudLab/frostfs-node/internal/logs"
|
|
"git.frostfs.info/TrueCloudLab/frostfs-node/internal/metrics"
|
|
morphmetrics "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/morph/metrics"
|
|
"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/util/logger"
|
|
"github.com/google/uuid"
|
|
lru "github.com/hashicorp/golang-lru/v2"
|
|
"github.com/nspcc-dev/neo-go/pkg/core/native/noderoles"
|
|
"github.com/nspcc-dev/neo-go/pkg/core/transaction"
|
|
"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
|
|
"github.com/nspcc-dev/neo-go/pkg/encoding/fixedn"
|
|
"github.com/nspcc-dev/neo-go/pkg/neorpc/result"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpcclient"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpcclient/actor"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpcclient/gas"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpcclient/invoker"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpcclient/nep17"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpcclient/rolemgmt"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpcclient/unwrap"
|
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract"
|
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger"
|
|
"github.com/nspcc-dev/neo-go/pkg/util"
|
|
"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
|
|
"github.com/nspcc-dev/neo-go/pkg/vm/vmstate"
|
|
"github.com/nspcc-dev/neo-go/pkg/wallet"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Client is a wrapper over web socket neo-go client
|
|
// that provides smart-contract invocation interface
|
|
// and notification subscription functionality.
|
|
//
|
|
// On connection lost tries establishing new connection
|
|
// to the next RPC (if any). If no RPC node available,
|
|
// switches to inactive mode: any RPC call leads to immediate
|
|
// return with ErrConnectionLost error, notification channel
|
|
// returned from Client.NotificationChannel is closed.
|
|
//
|
|
// Working client must be created via constructor New.
|
|
// Using the Client that has been created with new(Client)
|
|
// expression (or just declaring a Client variable) is unsafe
|
|
// and can lead to panic.
|
|
type Client struct {
|
|
cache cache
|
|
|
|
logger *logger.Logger // logging component
|
|
metrics morphmetrics.Register
|
|
|
|
client *rpcclient.WSClient // neo-go websocket client
|
|
rpcActor *actor.Actor // neo-go RPC actor
|
|
gasToken *nep17.Token // neo-go GAS token wrapper
|
|
rolemgmt *rolemgmt.Contract // neo-go Designation contract wrapper
|
|
nnsHash util.Uint160 // NNS contract hash
|
|
|
|
acc *wallet.Account // neo account
|
|
accAddr util.Uint160 // account's address
|
|
|
|
notary *notaryInfo
|
|
|
|
cfg cfg
|
|
|
|
endpoints endpoints
|
|
|
|
// switchLock protects endpoints, inactive, and subscription-related fields.
|
|
// It is taken exclusively during endpoint switch and locked in shared mode
|
|
// on every normal call.
|
|
switchLock sync.RWMutex
|
|
|
|
// channel for internal stop
|
|
closeChan chan struct{}
|
|
closed atomic.Bool
|
|
wg sync.WaitGroup
|
|
|
|
// indicates that Client is not able to
|
|
// establish connection to any of the
|
|
// provided RPC endpoints
|
|
inactive bool
|
|
|
|
// indicates that Client has already started
|
|
// goroutine that tries to switch to the higher
|
|
// priority RPC node
|
|
switchIsActive atomic.Bool
|
|
}
|
|
|
|
type cache struct {
|
|
m sync.RWMutex
|
|
|
|
gKey *keys.PublicKey
|
|
txHeights *lru.Cache[util.Uint256, uint32]
|
|
|
|
metrics metrics.MorphCacheMetrics
|
|
}
|
|
|
|
func (c *cache) groupKey() *keys.PublicKey {
|
|
c.m.RLock()
|
|
defer c.m.RUnlock()
|
|
|
|
return c.gKey
|
|
}
|
|
|
|
func (c *cache) setGroupKey(groupKey *keys.PublicKey) {
|
|
c.m.Lock()
|
|
defer c.m.Unlock()
|
|
|
|
c.gKey = groupKey
|
|
}
|
|
|
|
func (c *cache) invalidate() {
|
|
c.m.Lock()
|
|
defer c.m.Unlock()
|
|
|
|
c.gKey = nil
|
|
c.txHeights.Purge()
|
|
}
|
|
|
|
var (
|
|
// ErrNilClient is returned by functions that expect
|
|
// a non-nil Client pointer, but received nil.
|
|
ErrNilClient = errors.New("client is nil")
|
|
|
|
// ErrConnectionLost is returned when client lost web socket connection
|
|
// to the RPC node and has not been able to establish a new one since.
|
|
ErrConnectionLost = errors.New("connection to the RPC node has been lost")
|
|
)
|
|
|
|
// HaltState returned if TestInvoke function processed without panic.
|
|
const HaltState = "HALT"
|
|
|
|
type notHaltStateError struct {
|
|
state, exception string
|
|
}
|
|
|
|
func (e *notHaltStateError) Error() string {
|
|
return fmt.Sprintf(
|
|
"chain/client: contract execution finished with state %s; exception: %s",
|
|
e.state,
|
|
e.exception,
|
|
)
|
|
}
|
|
|
|
// implementation of error interface for FrostFS-specific errors.
|
|
type frostfsError struct {
|
|
err error
|
|
}
|
|
|
|
func (e frostfsError) Error() string {
|
|
return fmt.Sprintf("frostfs error: %v", e.err)
|
|
}
|
|
|
|
// wraps FrostFS-specific error into frostfsError. Arg must not be nil.
|
|
func wrapFrostFSError(err error) error {
|
|
return frostfsError{err}
|
|
}
|
|
|
|
// Invoke invokes contract method by sending transaction into blockchain.
|
|
// Returns valid until block value.
|
|
// Supported args types: int64, string, util.Uint160, []byte and bool.
|
|
func (c *Client) Invoke(ctx context.Context, contract util.Uint160, fee fixedn.Fixed8, method string, args ...any) (InvokeRes, error) {
|
|
start := time.Now()
|
|
success := false
|
|
defer func() {
|
|
c.metrics.ObserveInvoke("Invoke", contract.String(), method, success, time.Since(start))
|
|
}()
|
|
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return InvokeRes{}, ErrConnectionLost
|
|
}
|
|
|
|
txHash, vub, err := c.rpcActor.SendTunedCall(contract, method, nil, addFeeCheckerModifier(int64(fee)), args...)
|
|
if err != nil {
|
|
return InvokeRes{}, fmt.Errorf("invoke %s: %w", method, err)
|
|
}
|
|
|
|
c.logger.Debug(ctx, logs.ClientNeoClientInvoke,
|
|
zap.String("method", method),
|
|
zap.Uint32("vub", vub),
|
|
zap.Stringer("tx_hash", txHash.Reverse()))
|
|
|
|
success = true
|
|
return InvokeRes{Hash: txHash, VUB: vub}, nil
|
|
}
|
|
|
|
// TestInvokeIterator invokes contract method returning an iterator and executes cb on each element.
|
|
// If cb returns an error, the session is closed and this error is returned as-is.
|
|
// If the remote neo-go node does not support sessions, `unwrap.ErrNoSessionID` is returned.
|
|
// batchSize is the number of items to prefetch: if the number of items in the iterator is less than batchSize, no session will be created.
|
|
// The default batchSize is 100, the default limit from neo-go.
|
|
func (c *Client) TestInvokeIterator(cb func(stackitem.Item) error, batchSize int, contract util.Uint160, method string, args ...any) error {
|
|
start := time.Now()
|
|
success := false
|
|
defer func() {
|
|
c.metrics.ObserveInvoke("TestInvokeIterator", contract.String(), method, success, time.Since(start))
|
|
}()
|
|
|
|
if batchSize <= 0 {
|
|
batchSize = invoker.DefaultIteratorResultItems
|
|
}
|
|
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return ErrConnectionLost
|
|
}
|
|
|
|
script, err := smartcontract.CreateCallAndPrefetchIteratorScript(contract, method, batchSize, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
val, err := c.rpcActor.Run(script)
|
|
if err != nil {
|
|
return err
|
|
} else if val.State != HaltState {
|
|
return wrapFrostFSError(¬HaltStateError{state: val.State, exception: val.FaultException})
|
|
}
|
|
|
|
arr, sid, r, err := unwrap.ArrayAndSessionIterator(val, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range arr {
|
|
if err := cb(arr[i]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if (sid == uuid.UUID{}) {
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
defer func() {
|
|
_ = c.rpcActor.TerminateSession(sid)
|
|
}()
|
|
|
|
// Batch size for TraverseIterator() can restricted on the server-side.
|
|
traverseBatchSize := min(batchSize, invoker.DefaultIteratorResultItems)
|
|
for {
|
|
items, err := c.rpcActor.TraverseIterator(sid, &r, traverseBatchSize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := range items {
|
|
if err := cb(items[i]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(items) < traverseBatchSize {
|
|
break
|
|
}
|
|
}
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
// TestInvoke invokes contract method locally in neo-go node. This method should
|
|
// be used to read data from smart-contract.
|
|
func (c *Client) TestInvoke(contract util.Uint160, method string, args ...any) (res []stackitem.Item, err error) {
|
|
start := time.Now()
|
|
success := false
|
|
defer func() {
|
|
c.metrics.ObserveInvoke("TestInvoke", contract.String(), method, success, time.Since(start))
|
|
}()
|
|
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return nil, ErrConnectionLost
|
|
}
|
|
|
|
val, err := c.rpcActor.Call(contract, method, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if val.State != HaltState {
|
|
return nil, wrapFrostFSError(¬HaltStateError{state: val.State, exception: val.FaultException})
|
|
}
|
|
|
|
success = true
|
|
return val.Stack, nil
|
|
}
|
|
|
|
// TransferGas to the receiver from local wallet.
|
|
func (c *Client) TransferGas(receiver util.Uint160, amount fixedn.Fixed8) error {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return ErrConnectionLost
|
|
}
|
|
|
|
txHash, vub, err := c.gasToken.Transfer(c.accAddr, receiver, big.NewInt(int64(amount)), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.logger.Debug(context.Background(), logs.ClientNativeGasTransferInvoke,
|
|
zap.String("to", receiver.StringLE()),
|
|
zap.Stringer("tx_hash", txHash.Reverse()),
|
|
zap.Uint32("vub", vub))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) BatchTransferGas(receivers []util.Uint160, amount fixedn.Fixed8) error {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return ErrConnectionLost
|
|
}
|
|
|
|
transferParams := make([]nep17.TransferParameters, len(receivers))
|
|
receiversLog := make([]string, len(receivers))
|
|
|
|
for i, receiver := range receivers {
|
|
transferParams[i] = nep17.TransferParameters{
|
|
From: c.accAddr,
|
|
To: receiver,
|
|
Amount: big.NewInt(int64(amount)),
|
|
Data: nil,
|
|
}
|
|
receiversLog[i] = receiver.StringLE()
|
|
}
|
|
|
|
txHash, vub, err := c.gasToken.MultiTransfer(transferParams)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.logger.Debug(context.Background(), logs.ClientBatchGasTransferInvoke,
|
|
zap.Strings("to", receiversLog),
|
|
zap.Stringer("tx_hash", txHash.Reverse()),
|
|
zap.Uint32("vub", vub))
|
|
|
|
return nil
|
|
}
|
|
|
|
// Wait function blocks routing execution until there
|
|
// are `n` new blocks in the chain.
|
|
//
|
|
// Returns only connection errors.
|
|
func (c *Client) Wait(ctx context.Context, n uint32) error {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return ErrConnectionLost
|
|
}
|
|
|
|
var (
|
|
err error
|
|
height, newHeight uint32
|
|
)
|
|
|
|
height, err = c.rpcActor.GetBlockCount()
|
|
if err != nil {
|
|
c.logger.Error(ctx, logs.ClientCantGetBlockchainHeight,
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
newHeight, err = c.rpcActor.GetBlockCount()
|
|
if err != nil {
|
|
c.logger.Error(ctx, logs.ClientCantGetBlockchainHeight243,
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
|
|
if newHeight >= height+n {
|
|
return nil
|
|
}
|
|
|
|
time.Sleep(c.cfg.waitInterval)
|
|
}
|
|
}
|
|
|
|
// GasBalance returns GAS amount in the client's wallet.
|
|
func (c *Client) GasBalance() (res int64, err error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return 0, ErrConnectionLost
|
|
}
|
|
|
|
bal, err := c.gasToken.BalanceOf(c.accAddr)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return bal.Int64(), nil
|
|
}
|
|
|
|
// Committee returns keys of chain committee from neo native contract.
|
|
func (c *Client) Committee() (res keys.PublicKeys, err error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return nil, ErrConnectionLost
|
|
}
|
|
|
|
return c.client.GetCommittee()
|
|
}
|
|
|
|
// TxHalt returns true if transaction has been successfully executed and persisted.
|
|
func (c *Client) TxHalt(h util.Uint256) (res bool, err error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return false, ErrConnectionLost
|
|
}
|
|
|
|
trig := trigger.Application
|
|
aer, err := c.client.GetApplicationLog(h, &trig)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return len(aer.Executions) > 0 && aer.Executions[0].VMState.HasFlag(vmstate.Halt), nil
|
|
}
|
|
|
|
func (c *Client) GetApplicationLog(hash util.Uint256, trig *trigger.Type) (*result.ApplicationLog, error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return nil, ErrConnectionLost
|
|
}
|
|
|
|
return c.client.GetApplicationLog(hash, trig)
|
|
}
|
|
|
|
func (c *Client) GetVersion() (*result.Version, error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return nil, ErrConnectionLost
|
|
}
|
|
|
|
return c.client.GetVersion()
|
|
}
|
|
|
|
// TxHeight returns true if transaction has been successfully executed and persisted.
|
|
func (c *Client) TxHeight(h util.Uint256) (res uint32, err error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return 0, ErrConnectionLost
|
|
}
|
|
|
|
return c.client.GetTransactionHeight(h)
|
|
}
|
|
|
|
// NeoFSAlphabetList returns keys that stored in NeoFS Alphabet role. Main chain
|
|
// stores alphabet node keys of inner ring there, however the sidechain stores both
|
|
// alphabet and non alphabet node keys of inner ring.
|
|
func (c *Client) NeoFSAlphabetList(_ context.Context) (res keys.PublicKeys, err error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return nil, ErrConnectionLost
|
|
}
|
|
|
|
list, err := c.roleList(noderoles.NeoFSAlphabet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get alphabet nodes role list: %w", err)
|
|
}
|
|
|
|
return list, nil
|
|
}
|
|
|
|
// GetDesignateHash returns hash of the native `RoleManagement` contract.
|
|
func (c *Client) GetDesignateHash() util.Uint160 {
|
|
return rolemgmt.Hash
|
|
}
|
|
|
|
func (c *Client) roleList(r noderoles.Role) (keys.PublicKeys, error) {
|
|
height, err := c.rpcActor.GetBlockCount()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get chain height: %w", err)
|
|
}
|
|
|
|
return c.rolemgmt.GetDesignatedByRole(r, height)
|
|
}
|
|
|
|
// MagicNumber returns the magic number of the network
|
|
// to which the underlying RPC node client is connected.
|
|
func (c *Client) MagicNumber() (uint64, error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return 0, ErrConnectionLost
|
|
}
|
|
|
|
return uint64(c.rpcActor.GetNetwork()), nil
|
|
}
|
|
|
|
// BlockCount returns block count of the network
|
|
// to which the underlying RPC node client is connected.
|
|
func (c *Client) BlockCount() (res uint32, err error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return 0, ErrConnectionLost
|
|
}
|
|
|
|
return c.rpcActor.GetBlockCount()
|
|
}
|
|
|
|
// MsPerBlock returns MillisecondsPerBlock network parameter.
|
|
func (c *Client) MsPerBlock() (res int64, err error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return 0, ErrConnectionLost
|
|
}
|
|
|
|
v := c.rpcActor.GetVersion()
|
|
|
|
return int64(v.Protocol.MillisecondsPerBlock), nil
|
|
}
|
|
|
|
// IsValidScript returns true if invocation script executes with HALT state.
|
|
func (c *Client) IsValidScript(script []byte, signers []transaction.Signer) (valid bool, err error) {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
if c.inactive {
|
|
return false, ErrConnectionLost
|
|
}
|
|
|
|
res, err := c.client.InvokeScript(script, signers)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invokeScript: %w", err)
|
|
}
|
|
|
|
return res.State == vmstate.Halt.String(), nil
|
|
}
|
|
|
|
func (c *Client) Metrics() morphmetrics.Register {
|
|
return c.metrics
|
|
}
|
|
|
|
func (c *Client) setActor(act *actor.Actor) {
|
|
c.rpcActor = act
|
|
c.gasToken = nep17.New(act, gas.Hash)
|
|
c.rolemgmt = rolemgmt.New(act)
|
|
}
|
|
|
|
func (c *Client) GetActor() *actor.Actor {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
return c.rpcActor
|
|
}
|
|
|
|
func (c *Client) GetRPCActor() actor.RPCActor {
|
|
c.switchLock.RLock()
|
|
defer c.switchLock.RUnlock()
|
|
|
|
return c.client
|
|
}
|