package client import ( "context" "errors" "fmt" "sync" "time" lru "github.com/hashicorp/golang-lru" "github.com/nspcc-dev/neo-go/pkg/core/native/nativenames" "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/rpc/client" sc "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" "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" "github.com/nspcc-dev/neo-go/pkg/wallet" "github.com/nspcc-dev/neofs-node/pkg/util/logger" "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 logger *logger.Logger // logging component client *client.WSClient // neo-go websocket client acc *wallet.Account // neo account signer *transaction.Signer notary *notary cfg cfg endpoints *endpoints // switching between rpc endpoint lock switchLock *sync.RWMutex // channel for ws notifications notifications chan client.Notification // channel for internal stop closeChan chan struct{} // cached subscription information subscribedEvents map[util.Uint160]string subscribedNotaryEvents map[util.Uint160]string subscribedToNewBlocks bool // indicates that Client is not able to // establish connection to any of the // provided RPC endpoints inactive bool } type cache struct { nnsHash util.Uint160 groupKey *keys.PublicKey txHeights *lru.Cache } 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, ) } var errEmptyInvocationScript = errors.New("got empty invocation script from neo node") // implementation of error interface for NeoFS-specific errors. type neofsError struct { err error } func (e neofsError) Error() string { return fmt.Sprintf("neofs error: %v", e.err) } // wraps NeoFS-specific error into neofsError. Arg must not be nil. func wrapNeoFSError(err error) error { return neofsError{err} } // unwraps NeoFS-specific error if err is type of neofsError. Otherwise, returns nil. func unwrapNeoFSError(err error) error { if e := new(neofsError); errors.As(err, e) { return e.err } return nil } // Invoke invokes contract method by sending transaction into blockchain. // Supported args types: int64, string, util.Uint160, []byte and bool. func (c *Client) Invoke(contract util.Uint160, fee fixedn.Fixed8, method string, args ...interface{}) error { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return ErrConnectionLost } params := make([]sc.Parameter, 0, len(args)) for i := range args { param, err := toStackParameter(args[i]) if err != nil { return err } params = append(params, param) } cosigner := []transaction.Signer{ { Account: c.acc.PrivateKey().PublicKey().GetScriptHash(), Scopes: c.signer.Scopes, AllowedContracts: c.signer.AllowedContracts, AllowedGroups: c.signer.AllowedGroups, }, } cosignerAcc := []client.SignerAccount{ { Signer: cosigner[0], Account: c.acc, }, } resp, err := c.client.InvokeFunction(contract, method, params, cosigner) if err != nil { return err } if resp.State != HaltState { return wrapNeoFSError(¬HaltStateError{state: resp.State, exception: resp.FaultException}) } if len(resp.Script) == 0 { return wrapNeoFSError(errEmptyInvocationScript) } script := resp.Script sysFee := resp.GasConsumed + int64(fee) // consumed gas + extra fee txHash, err := c.client.SignAndPushInvocationTx(script, c.acc, sysFee, 0, cosignerAcc) if err != nil { return err } c.logger.Debug("neo client invoke", zap.String("method", method), zap.Stringer("tx_hash", txHash.Reverse())) 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 ...interface{}) (res []stackitem.Item, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return nil, ErrConnectionLost } var params = make([]sc.Parameter, 0, len(args)) for i := range args { p, err := toStackParameter(args[i]) if err != nil { return nil, err } params = append(params, p) } cosigner := []transaction.Signer{ { Account: c.acc.PrivateKey().PublicKey().GetScriptHash(), Scopes: transaction.Global, }, } val, err := c.client.InvokeFunction(contract, method, params, cosigner) if err != nil { return nil, err } if val.State != HaltState { return nil, wrapNeoFSError(¬HaltStateError{state: val.State, exception: val.FaultException}) } 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 } gas, err := c.client.GetNativeContractHash(nativenames.Gas) if err != nil { return err } txHash, err := c.client.TransferNEP17(c.acc, receiver, gas, int64(amount), 0, nil, nil) if err != nil { return err } c.logger.Debug("native gas transfer invoke", zap.String("to", receiver.StringLE()), zap.Stringer("tx_hash", txHash.Reverse())) 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.client.GetBlockCount() if err != nil { c.logger.Error("can't get blockchain height", zap.String("error", err.Error())) return nil } for { select { case <-ctx.Done(): return nil default: } newHeight, err = c.client.GetBlockCount() if err != nil { c.logger.Error("can't get blockchain height", zap.String("error", err.Error())) 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 } gas, err := c.client.GetNativeContractHash(nativenames.Gas) if err != nil { return 0, err } return c.client.NEP17BalanceOf(gas, c.acc.PrivateKey().GetScriptHash()) } // 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(vm.HaltState), nil } // 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 side chain stores both // alphabet and non alphabet node keys of inner ring. func (c *Client) NeoFSAlphabetList() (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("can't get alphabet nodes role list: %w", err) } return list, nil } // GetDesignateHash returns hash of the native `RoleManagement` contract. func (c *Client) GetDesignateHash() (res util.Uint160, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return util.Uint160{}, ErrConnectionLost } return c.client.GetNativeContractHash(nativenames.Designation) } func (c *Client) roleList(r noderoles.Role) (keys.PublicKeys, error) { height, err := c.client.GetBlockCount() if err != nil { return nil, fmt.Errorf("can't get chain height: %w", err) } return c.client.GetDesignatedByRole(r, height) } // tries to resolve sc.Parameter from the arg. // // Wraps any error to neofsError. func toStackParameter(value interface{}) (sc.Parameter, error) { var result = sc.Parameter{ Value: value, } // TODO: #1141 add more types switch v := value.(type) { case []byte: result.Type = sc.ByteArrayType case int64: result.Type = sc.IntegerType case [][]byte: arr := make([]sc.Parameter, 0, len(v)) for i := range v { elem, err := toStackParameter(v[i]) if err != nil { return result, err } arr = append(arr, elem) } result.Type = sc.ArrayType result.Value = arr case string: result.Type = sc.StringType case util.Uint160: result.Type = sc.ByteArrayType result.Value = v.BytesBE() case noderoles.Role: result.Type = sc.IntegerType result.Value = int64(v) case keys.PublicKeys: arr := make([][]byte, 0, len(v)) for i := range v { arr = append(arr, v[i].Bytes()) } return toStackParameter(arr) case bool: // FIXME: #1141 there are some problems with BoolType in neo-go, // so we use compatible type result.Type = sc.IntegerType if v { result.Value = int64(1) } else { result.Value = int64(0) } default: return result, wrapNeoFSError(fmt.Errorf("chain/client: unsupported parameter %v", value)) } return result, nil } // MagicNumber returns the magic number of the network // to which the underlying RPC node client is connected. // // Returns 0 in case of connection problems. func (c *Client) MagicNumber() (res uint64, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return 0, ErrConnectionLost } return uint64(c.client.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.client.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, err := c.client.GetVersion() if err != nil { return 0, fmt.Errorf("getVersion: %w", err) } 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) (res bool, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return false, ErrConnectionLost } result, err := c.client.InvokeScript(script, signers) if err != nil { return false, fmt.Errorf("invokeScript: %w", err) } return result.State == vm.HaltState.String(), nil } // NotificationChannel returns channel than receives subscribed // notification from the connected RPC node. // Channel is closed when connection to the RPC node has been // lost without the possibility of recovery. func (c *Client) NotificationChannel() <-chan client.Notification { return c.notifications } // inactiveMode switches Client to an inactive mode: // - notification channel is closed; // - all the new RPC request would return ErrConnectionLost. // // Note: must be called only with held write switchLock. func (c *Client) inactiveMode() { close(c.notifications) c.inactive = true }