github.com/nspcc-dev/neo-go@v0.105.2-0.20240517133400-6be757af3eba/pkg/services/notary/notary.go (about)

     1  package notary
     2  
     3  import (
     4  	"bytes"
     5  	"crypto/elliptic"
     6  	"encoding/hex"
     7  	"errors"
     8  	"fmt"
     9  	"sync"
    10  	"sync/atomic"
    11  
    12  	"github.com/nspcc-dev/neo-go/pkg/config"
    13  	"github.com/nspcc-dev/neo-go/pkg/config/netmode"
    14  	"github.com/nspcc-dev/neo-go/pkg/core/block"
    15  	"github.com/nspcc-dev/neo-go/pkg/core/mempool"
    16  	"github.com/nspcc-dev/neo-go/pkg/core/mempoolevent"
    17  	"github.com/nspcc-dev/neo-go/pkg/core/transaction"
    18  	"github.com/nspcc-dev/neo-go/pkg/crypto/hash"
    19  	"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
    20  	"github.com/nspcc-dev/neo-go/pkg/io"
    21  	"github.com/nspcc-dev/neo-go/pkg/network/payload"
    22  	"github.com/nspcc-dev/neo-go/pkg/util"
    23  	"github.com/nspcc-dev/neo-go/pkg/vm"
    24  	"github.com/nspcc-dev/neo-go/pkg/vm/opcode"
    25  	"github.com/nspcc-dev/neo-go/pkg/wallet"
    26  	"go.uber.org/zap"
    27  )
    28  
    29  type (
    30  	// Ledger is the interface to Blockchain sufficient for Notary.
    31  	Ledger interface {
    32  		BlockHeight() uint32
    33  		GetMaxVerificationGAS() int64
    34  		GetNotaryContractScriptHash() util.Uint160
    35  		SubscribeForBlocks(ch chan *block.Block)
    36  		UnsubscribeFromBlocks(ch chan *block.Block)
    37  		VerifyWitness(util.Uint160, hash.Hashable, *transaction.Witness, int64) (int64, error)
    38  	}
    39  
    40  	// Notary represents a Notary module.
    41  	Notary struct {
    42  		Config Config
    43  
    44  		Network netmode.Magic
    45  
    46  		// onTransaction is a callback for completed transactions (mains or fallbacks) sending.
    47  		onTransaction func(tx *transaction.Transaction) error
    48  		// newTxs is a channel where new transactions are sent
    49  		// to be processed in an `onTransaction` callback.
    50  		newTxs chan txHashPair
    51  		// started is a status bool to protect from double start/shutdown.
    52  		started atomic.Bool
    53  
    54  		// reqMtx protects requests list.
    55  		reqMtx sync.RWMutex
    56  		// requests represents a map of main transactions which needs to be completed
    57  		// with the associated fallback transactions grouped by the main transaction hash
    58  		requests map[util.Uint256]*request
    59  
    60  		// accMtx protects account.
    61  		accMtx      sync.RWMutex
    62  		currAccount *wallet.Account
    63  		wallet      *wallet.Wallet
    64  
    65  		mp *mempool.Pool
    66  		// requests channel
    67  		reqCh chan mempoolevent.Event
    68  		// blocksCh is a channel used to receive block notifications from the
    69  		// Blockchain. It is not buffered intentionally, as it's important to keep
    70  		// the notary request pool in sync with the current blockchain heigh, thus,
    71  		// it's not recommended to use a large size of notary requests pool as it may
    72  		// slow down the block processing.
    73  		blocksCh chan *block.Block
    74  		stopCh   chan struct{}
    75  		done     chan struct{}
    76  	}
    77  
    78  	// Config represents external configuration for Notary module.
    79  	Config struct {
    80  		MainCfg config.P2PNotary
    81  		Chain   Ledger
    82  		Log     *zap.Logger
    83  	}
    84  )
    85  
    86  const defaultTxChannelCapacity = 100
    87  
    88  type (
    89  	// request represents Notary service request.
    90  	request struct {
    91  		// isSent indicates whether the main transaction was successfully sent to the network.
    92  		isSent bool
    93  		main   *transaction.Transaction
    94  		// minNotValidBefore is the minimum NVB value among fallbacks transactions.
    95  		// We stop trying to send the mainTx to the network if the chain reaches the minNotValidBefore height.
    96  		minNotValidBefore uint32
    97  		fallbacks         []*transaction.Transaction
    98  
    99  		witnessInfo []witnessInfo
   100  	}
   101  
   102  	// witnessInfo represents information about the signer and its witness.
   103  	witnessInfo struct {
   104  		typ RequestType
   105  		// nSigsLeft is the number of signatures left to collect to complete the main transaction.
   106  		// Initial nSigsLeft value is defined as following:
   107  		// nSigsLeft == nKeys for standard signature request;
   108  		// nSigsLeft <= nKeys for multisignature request;
   109  		nSigsLeft uint8
   110  
   111  		// sigs is a map of partial multisig invocation scripts [opcode.PUSHDATA1+64+signatureBytes] grouped by public keys.
   112  		sigs map[*keys.PublicKey][]byte
   113  		// pubs is a set of public keys participating in the multisignature witness collection.
   114  		pubs keys.PublicKeys
   115  	}
   116  )
   117  
   118  // isMainCompleted denotes whether all signatures for the main transaction were collected.
   119  func (r request) isMainCompleted() bool {
   120  	if r.witnessInfo == nil {
   121  		return false
   122  	}
   123  	for _, wi := range r.witnessInfo {
   124  		if wi.nSigsLeft != 0 {
   125  			return false
   126  		}
   127  	}
   128  	return true
   129  }
   130  
   131  // NewNotary returns a new Notary module.
   132  func NewNotary(cfg Config, net netmode.Magic, mp *mempool.Pool, onTransaction func(tx *transaction.Transaction) error) (*Notary, error) {
   133  	w := cfg.MainCfg.UnlockWallet
   134  	wallet, err := wallet.NewWalletFromFile(w.Path)
   135  	if err != nil {
   136  		return nil, err
   137  	}
   138  
   139  	haveAccount := false
   140  	for _, acc := range wallet.Accounts {
   141  		if err := acc.Decrypt(w.Password, wallet.Scrypt); err == nil {
   142  			haveAccount = true
   143  			break
   144  		}
   145  	}
   146  	if !haveAccount {
   147  		return nil, errors.New("no wallet account could be unlocked")
   148  	}
   149  
   150  	return &Notary{
   151  		requests:      make(map[util.Uint256]*request),
   152  		Config:        cfg,
   153  		Network:       net,
   154  		wallet:        wallet,
   155  		onTransaction: onTransaction,
   156  		newTxs:        make(chan txHashPair, defaultTxChannelCapacity),
   157  		mp:            mp,
   158  		reqCh:         make(chan mempoolevent.Event),
   159  		blocksCh:      make(chan *block.Block),
   160  		stopCh:        make(chan struct{}),
   161  		done:          make(chan struct{}),
   162  	}, nil
   163  }
   164  
   165  // Name returns service name.
   166  func (n *Notary) Name() string {
   167  	return "notary"
   168  }
   169  
   170  // Start runs a Notary module in a separate goroutine.
   171  // The Notary only starts once, subsequent calls to Start are no-op.
   172  func (n *Notary) Start() {
   173  	if !n.started.CompareAndSwap(false, true) {
   174  		return
   175  	}
   176  	n.Config.Log.Info("starting notary service")
   177  	go n.newTxCallbackLoop()
   178  	go n.mainLoop()
   179  }
   180  
   181  func (n *Notary) mainLoop() {
   182  	n.Config.Chain.SubscribeForBlocks(n.blocksCh)
   183  	n.mp.SubscribeForTransactions(n.reqCh)
   184  mainloop:
   185  	for {
   186  		select {
   187  		case <-n.stopCh:
   188  			n.mp.UnsubscribeFromTransactions(n.reqCh)
   189  			n.Config.Chain.UnsubscribeFromBlocks(n.blocksCh)
   190  			break mainloop
   191  		case event := <-n.reqCh:
   192  			if req, ok := event.Data.(*payload.P2PNotaryRequest); ok {
   193  				switch event.Type {
   194  				case mempoolevent.TransactionAdded:
   195  					n.OnNewRequest(req)
   196  				case mempoolevent.TransactionRemoved:
   197  					n.OnRequestRemoval(req)
   198  				}
   199  			}
   200  		case <-n.blocksCh:
   201  			// a new block was added, we need to check for valid fallbacks
   202  			n.PostPersist()
   203  		}
   204  	}
   205  drainLoop:
   206  	for {
   207  		select {
   208  		case <-n.blocksCh:
   209  		case <-n.reqCh:
   210  		default:
   211  			break drainLoop
   212  		}
   213  	}
   214  	close(n.blocksCh)
   215  	close(n.reqCh)
   216  	close(n.done)
   217  }
   218  
   219  // Shutdown stops the Notary module. It can only be called once, subsequent calls
   220  // to Shutdown on the same instance are no-op. The instance that was stopped can
   221  // not be started again by calling Start (use a new instance if needed).
   222  func (n *Notary) Shutdown() {
   223  	if !n.started.CompareAndSwap(true, false) {
   224  		return
   225  	}
   226  	n.Config.Log.Info("stopping notary service")
   227  	close(n.stopCh)
   228  	<-n.done
   229  	n.wallet.Close()
   230  	_ = n.Config.Log.Sync()
   231  }
   232  
   233  // IsAuthorized returns whether Notary service currently is authorized to collect
   234  // signatures. It returnes true iff designated Notary node's account provided to
   235  // the Notary service in decrypted state.
   236  func (n *Notary) IsAuthorized() bool {
   237  	return n.getAccount() != nil
   238  }
   239  
   240  // OnNewRequest is a callback method which is called after a new notary request is added to the notary request pool.
   241  func (n *Notary) OnNewRequest(payload *payload.P2PNotaryRequest) {
   242  	if !n.started.Load() {
   243  		return
   244  	}
   245  	acc := n.getAccount()
   246  	if acc == nil {
   247  		return
   248  	}
   249  
   250  	nvbFallback := payload.FallbackTransaction.GetAttributes(transaction.NotValidBeforeT)[0].Value.(*transaction.NotValidBefore).Height
   251  	nKeys := payload.MainTransaction.GetAttributes(transaction.NotaryAssistedT)[0].Value.(*transaction.NotaryAssisted).NKeys
   252  	newInfo, validationErr := n.verifyIncompleteWitnesses(payload.MainTransaction, nKeys)
   253  	if validationErr != nil {
   254  		n.Config.Log.Info("verification of main notary transaction failed; fallback transaction will be completed",
   255  			zap.String("main hash", payload.MainTransaction.Hash().StringLE()),
   256  			zap.String("fallback hash", payload.FallbackTransaction.Hash().StringLE()),
   257  			zap.String("verification error", validationErr.Error()))
   258  	}
   259  	n.reqMtx.Lock()
   260  	defer n.reqMtx.Unlock()
   261  	r, exists := n.requests[payload.MainTransaction.Hash()]
   262  	if exists {
   263  		for _, fb := range r.fallbacks {
   264  			if fb.Hash().Equals(payload.FallbackTransaction.Hash()) {
   265  				return // then we already have processed this request
   266  			}
   267  		}
   268  		if nvbFallback < r.minNotValidBefore {
   269  			r.minNotValidBefore = nvbFallback
   270  		}
   271  	} else {
   272  		// Avoid changes in the main transaction witnesses got from the notary request pool to
   273  		// keep the pooled tx valid. We will update its copy => the copy's size will be changed.
   274  		r = &request{
   275  			main:              payload.MainTransaction.Copy(),
   276  			minNotValidBefore: nvbFallback,
   277  		}
   278  		n.requests[payload.MainTransaction.Hash()] = r
   279  	}
   280  	if r.witnessInfo == nil && validationErr == nil {
   281  		r.witnessInfo = newInfo
   282  	}
   283  	// Disallow modification of a fallback transaction got from the notary
   284  	// request pool. Even though it has dummy Notary witness attached and its
   285  	// size won't be changed after finalisation, the witness bytes changes may
   286  	// affect the other users of notary pool and cause race. Avoid this by making
   287  	// the copy.
   288  	r.fallbacks = append(r.fallbacks, payload.FallbackTransaction.Copy())
   289  	if exists && r.isMainCompleted() || validationErr != nil {
   290  		return
   291  	}
   292  	mainHash := hash.NetSha256(uint32(n.Network), r.main).BytesBE()
   293  	for i, w := range payload.MainTransaction.Scripts {
   294  		if len(w.InvocationScript) == 0 || // check that signature for this witness was provided
   295  			(r.witnessInfo[i].nSigsLeft == 0 && r.witnessInfo[i].typ != Contract) { // check that signature wasn't yet added (consider receiving the same payload multiple times)
   296  			continue
   297  		}
   298  		switch r.witnessInfo[i].typ {
   299  		case Contract:
   300  			// Need to check even if r.main.Scripts[i].InvocationScript is already filled in.
   301  			_, err := n.Config.Chain.VerifyWitness(r.main.Signers[i].Account, r.main, &w, n.Config.Chain.GetMaxVerificationGAS())
   302  			if err != nil {
   303  				continue
   304  			}
   305  			r.main.Scripts[i].InvocationScript = w.InvocationScript
   306  		case Signature:
   307  			if r.witnessInfo[i].pubs[0].Verify(w.InvocationScript[2:], mainHash) {
   308  				r.main.Scripts[i] = w
   309  				r.witnessInfo[i].nSigsLeft--
   310  			}
   311  		case MultiSignature:
   312  			if r.witnessInfo[i].sigs == nil {
   313  				r.witnessInfo[i].sigs = make(map[*keys.PublicKey][]byte)
   314  			}
   315  
   316  			for _, pub := range r.witnessInfo[i].pubs {
   317  				if r.witnessInfo[i].sigs[pub] != nil {
   318  					continue // signature for this pub has already been added
   319  				}
   320  				if pub.Verify(w.InvocationScript[2:], mainHash) { // then pub is the owner of the signature
   321  					r.witnessInfo[i].sigs[pub] = w.InvocationScript
   322  					r.witnessInfo[i].nSigsLeft--
   323  					if r.witnessInfo[i].nSigsLeft == 0 {
   324  						var invScript []byte
   325  						for j := range r.witnessInfo[i].pubs {
   326  							if sig, ok := r.witnessInfo[i].sigs[r.witnessInfo[i].pubs[j]]; ok {
   327  								invScript = append(invScript, sig...)
   328  							}
   329  						}
   330  						r.main.Scripts[i].InvocationScript = invScript
   331  					}
   332  					break
   333  				}
   334  			}
   335  			// pubKey was not found for the signature (i.e. signature is bad) or the signature has already
   336  			// been added - we're OK with that, let the fallback TX to be added
   337  		}
   338  	}
   339  	if r.isMainCompleted() && r.minNotValidBefore > n.Config.Chain.BlockHeight() {
   340  		if err := n.finalize(acc, r.main, payload.MainTransaction.Hash()); err != nil {
   341  			n.Config.Log.Error("failed to finalize main transaction",
   342  				zap.String("hash", r.main.Hash().StringLE()),
   343  				zap.Error(err))
   344  		}
   345  	}
   346  }
   347  
   348  // OnRequestRemoval is a callback which is called after fallback transaction is removed
   349  // from the notary payload pool due to expiration, main tx appliance or any other reason.
   350  func (n *Notary) OnRequestRemoval(pld *payload.P2PNotaryRequest) {
   351  	if !n.started.Load() || n.getAccount() == nil {
   352  		return
   353  	}
   354  
   355  	n.reqMtx.Lock()
   356  	defer n.reqMtx.Unlock()
   357  	r, ok := n.requests[pld.MainTransaction.Hash()]
   358  	if !ok {
   359  		return
   360  	}
   361  	for i, fb := range r.fallbacks {
   362  		if fb.Hash().Equals(pld.FallbackTransaction.Hash()) {
   363  			r.fallbacks = append(r.fallbacks[:i], r.fallbacks[i+1:]...)
   364  			break
   365  		}
   366  	}
   367  	if len(r.fallbacks) == 0 {
   368  		delete(n.requests, r.main.Hash())
   369  	}
   370  }
   371  
   372  // PostPersist is a callback which is called after a new block event is received.
   373  // PostPersist must not be called under the blockchain lock, because it uses finalization function.
   374  func (n *Notary) PostPersist() {
   375  	if !n.started.Load() {
   376  		return
   377  	}
   378  	acc := n.getAccount()
   379  	if acc == nil {
   380  		return
   381  	}
   382  
   383  	n.reqMtx.Lock()
   384  	defer n.reqMtx.Unlock()
   385  	currHeight := n.Config.Chain.BlockHeight()
   386  	for h, r := range n.requests {
   387  		if !r.isSent && r.isMainCompleted() && r.minNotValidBefore > currHeight {
   388  			if err := n.finalize(acc, r.main, h); err != nil {
   389  				n.Config.Log.Error("failed to finalize main transaction", zap.Error(err))
   390  			}
   391  			continue
   392  		}
   393  		if r.minNotValidBefore <= currHeight { // then at least one of the fallbacks can already be sent.
   394  			for _, fb := range r.fallbacks {
   395  				if nvb := fb.GetAttributes(transaction.NotValidBeforeT)[0].Value.(*transaction.NotValidBefore).Height; nvb <= currHeight {
   396  					// Ignore the error, wait for the next block to resend them
   397  					_ = n.finalize(acc, fb, h)
   398  				}
   399  			}
   400  		}
   401  	}
   402  }
   403  
   404  // finalize adds missing Notary witnesses to the transaction (main or fallback) and pushes it to the network.
   405  func (n *Notary) finalize(acc *wallet.Account, tx *transaction.Transaction, h util.Uint256) error {
   406  	notaryWitness := transaction.Witness{
   407  		InvocationScript:   append([]byte{byte(opcode.PUSHDATA1), keys.SignatureLen}, acc.SignHashable(n.Network, tx)...),
   408  		VerificationScript: []byte{},
   409  	}
   410  	for i, signer := range tx.Signers {
   411  		if signer.Account == n.Config.Chain.GetNotaryContractScriptHash() {
   412  			tx.Scripts[i] = notaryWitness
   413  			break
   414  		}
   415  	}
   416  	newTx, err := updateTxSize(tx)
   417  	if err != nil {
   418  		return fmt.Errorf("failed to update completed transaction's size: %w", err)
   419  	}
   420  
   421  	n.pushNewTx(newTx, h)
   422  
   423  	return nil
   424  }
   425  
   426  type txHashPair struct {
   427  	tx       *transaction.Transaction
   428  	mainHash util.Uint256
   429  }
   430  
   431  func (n *Notary) pushNewTx(tx *transaction.Transaction, h util.Uint256) {
   432  	select {
   433  	case n.newTxs <- txHashPair{tx, h}:
   434  	default:
   435  	}
   436  }
   437  
   438  func (n *Notary) newTxCallbackLoop() {
   439  	for {
   440  		select {
   441  		case tx := <-n.newTxs:
   442  			isMain := tx.tx.Hash() == tx.mainHash
   443  
   444  			n.reqMtx.Lock()
   445  			r, ok := n.requests[tx.mainHash]
   446  			if !ok || isMain && (r.isSent || r.minNotValidBefore <= n.Config.Chain.BlockHeight()) {
   447  				n.reqMtx.Unlock()
   448  				continue
   449  			}
   450  			if !isMain {
   451  				// Ensure that fallback was not already completed.
   452  				var isPending bool
   453  				for _, fb := range r.fallbacks {
   454  					if fb.Hash() == tx.tx.Hash() {
   455  						isPending = true
   456  						break
   457  					}
   458  				}
   459  				if !isPending {
   460  					n.reqMtx.Unlock()
   461  					continue
   462  				}
   463  			}
   464  
   465  			n.reqMtx.Unlock()
   466  			err := n.onTransaction(tx.tx)
   467  			if err != nil {
   468  				n.Config.Log.Error("new transaction callback finished with error",
   469  					zap.Error(err),
   470  					zap.Bool("is main", isMain))
   471  				continue
   472  			}
   473  
   474  			n.reqMtx.Lock()
   475  			if isMain {
   476  				r.isSent = true
   477  			} else {
   478  				for i := range r.fallbacks {
   479  					if r.fallbacks[i].Hash() == tx.tx.Hash() {
   480  						r.fallbacks = append(r.fallbacks[:i], r.fallbacks[i+1:]...)
   481  						break
   482  					}
   483  				}
   484  				if len(r.fallbacks) == 0 {
   485  					delete(n.requests, tx.mainHash)
   486  				}
   487  			}
   488  			n.reqMtx.Unlock()
   489  		case <-n.stopCh:
   490  			return
   491  		}
   492  	}
   493  }
   494  
   495  // updateTxSize returns a transaction with re-calculated size and an error.
   496  func updateTxSize(tx *transaction.Transaction) (*transaction.Transaction, error) {
   497  	bw := io.NewBufBinWriter()
   498  	tx.EncodeBinary(bw.BinWriter)
   499  	if bw.Err != nil {
   500  		return nil, fmt.Errorf("encode binary: %w", bw.Err)
   501  	}
   502  	return transaction.NewTransactionFromBytes(tx.Bytes())
   503  }
   504  
   505  // verifyIncompleteWitnesses checks that the tx either doesn't have all witnesses attached (in this case none of them
   506  // can be multisignature) or it only has a partial multisignature. It returns the request type (sig/multisig), the
   507  // number of signatures to be collected, sorted public keys (for multisig request only) and an error.
   508  func (n *Notary) verifyIncompleteWitnesses(tx *transaction.Transaction, nKeysExpected uint8) ([]witnessInfo, error) {
   509  	var nKeysActual uint8
   510  	if len(tx.Signers) < 2 {
   511  		return nil, errors.New("transaction should have at least 2 signers")
   512  	}
   513  	if !tx.HasSigner(n.Config.Chain.GetNotaryContractScriptHash()) {
   514  		return nil, fmt.Errorf("P2PNotary contract should be a signer of the transaction")
   515  	}
   516  	result := make([]witnessInfo, len(tx.Signers))
   517  	for i, w := range tx.Scripts {
   518  		// Do not check witness for a Notary contract -- it will be replaced by proper witness in any case.
   519  		// Also, do not check other contract-based witnesses (they can be combined with anything)
   520  		if len(w.VerificationScript) == 0 {
   521  			result[i] = witnessInfo{
   522  				typ:       Contract,
   523  				nSigsLeft: 0,
   524  			}
   525  			continue
   526  		}
   527  		if !tx.Signers[i].Account.Equals(hash.Hash160(w.VerificationScript)) { // https://github.com/nspcc-dev/neo-go/pull/1658#discussion_r564265987
   528  			return nil, fmt.Errorf("transaction should have valid verification script for signer #%d", i)
   529  		}
   530  		// Each verification script is allowed to have either one signature or zero signatures. If signature is provided, then need to verify it.
   531  		if len(w.InvocationScript) != 0 {
   532  			if len(w.InvocationScript) != 66 || !bytes.HasPrefix(w.InvocationScript, []byte{byte(opcode.PUSHDATA1), keys.SignatureLen}) {
   533  				return nil, fmt.Errorf("witness #%d: invocation script should have length = 66 and be of the form [PUSHDATA1, 64, signatureBytes...]", i)
   534  			}
   535  		}
   536  		if nSigs, pubsBytes, ok := vm.ParseMultiSigContract(w.VerificationScript); ok {
   537  			result[i] = witnessInfo{
   538  				typ:       MultiSignature,
   539  				nSigsLeft: uint8(nSigs),
   540  				pubs:      make(keys.PublicKeys, len(pubsBytes)),
   541  			}
   542  			for j, pBytes := range pubsBytes {
   543  				pub, err := keys.NewPublicKeyFromBytes(pBytes, elliptic.P256())
   544  				if err != nil {
   545  					return nil, fmt.Errorf("witness #%d: invalid bytes of #%d public key: %s", i, j, hex.EncodeToString(pBytes))
   546  				}
   547  				result[i].pubs[j] = pub
   548  			}
   549  			nKeysActual += uint8(len(pubsBytes))
   550  			continue
   551  		}
   552  		if pBytes, ok := vm.ParseSignatureContract(w.VerificationScript); ok {
   553  			pub, err := keys.NewPublicKeyFromBytes(pBytes, elliptic.P256())
   554  			if err != nil {
   555  				return nil, fmt.Errorf("witness #%d: invalid bytes of public key: %s", i, hex.EncodeToString(pBytes))
   556  			}
   557  			result[i] = witnessInfo{
   558  				typ:       Signature,
   559  				nSigsLeft: 1,
   560  				pubs:      keys.PublicKeys{pub},
   561  			}
   562  			nKeysActual++
   563  			continue
   564  		}
   565  		return nil, fmt.Errorf("witness #%d: unable to detect witness type, only sig/multisig/contract are supported", i)
   566  	}
   567  	if nKeysActual != nKeysExpected {
   568  		return nil, fmt.Errorf("expected and actual NKeys mismatch: %d vs %d", nKeysExpected, nKeysActual)
   569  	}
   570  	return result, nil
   571  }