package client import ( "crypto/elliptic" "encoding/binary" "errors" "fmt" "math" "math/big" "strings" "time" "git.frostfs.info/TrueCloudLab/frostfs-node/internal/logs" "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/util/rand" "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/hash" "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" "github.com/nspcc-dev/neo-go/pkg/neorpc/result" "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" "github.com/nspcc-dev/neo-go/pkg/rpcclient/notary" sc "github.com/nspcc-dev/neo-go/pkg/smartcontract" "github.com/nspcc-dev/neo-go/pkg/util" "github.com/nspcc-dev/neo-go/pkg/vm" "github.com/nspcc-dev/neo-go/pkg/vm/vmstate" "github.com/nspcc-dev/neo-go/pkg/wallet" "go.uber.org/zap" ) type ( notaryInfo struct { txValidTime uint32 // minimum amount of blocks when mainTx will be valid roundTime uint32 // extra amount of blocks to synchronize sidechain height diff of inner ring nodes alphabetSource AlphabetKeys // source of alphabet node keys to prepare witness notary util.Uint160 proxy util.Uint160 } notaryCfg struct { proxy util.Uint160 txValidTime, roundTime uint32 alphabetSource AlphabetKeys } AlphabetKeys func() (keys.PublicKeys, error) NotaryOption func(*notaryCfg) ) const ( defaultNotaryValidTime = 50 defaultNotaryRoundTime = 100 notaryBalanceOfMethod = "balanceOf" notaryExpirationOfMethod = "expirationOf" setDesignateMethod = "designateAsRole" notaryBalanceErrMsg = "can't fetch notary balance" notaryNotEnabledPanicMsg = "notary support was not enabled on this client" ) var errUnexpectedItems = errors.New("invalid number of NEO VM arguments on stack") func defaultNotaryConfig(c *Client) *notaryCfg { return ¬aryCfg{ txValidTime: defaultNotaryValidTime, roundTime: defaultNotaryRoundTime, alphabetSource: c.Committee, } } // EnableNotarySupport creates notary structure in client that provides // ability for client to get alphabet keys from committee or provided source // and use proxy contract script hash to create tx for notary contract. func (c *Client) EnableNotarySupport(opts ...NotaryOption) error { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return ErrConnectionLost } cfg := defaultNotaryConfig(c) for _, opt := range opts { opt(cfg) } if cfg.proxy.Equals(util.Uint160{}) { var err error cfg.proxy, err = c.NNSContractAddress(NNSProxyContractName) if err != nil { return fmt.Errorf("get proxy contract addess from NNS: %w", err) } } notaryCfg := ¬aryInfo{ proxy: cfg.proxy, txValidTime: cfg.txValidTime, roundTime: cfg.roundTime, alphabetSource: cfg.alphabetSource, notary: notary.Hash, } c.notary = notaryCfg return nil } // IsNotaryEnabled returns true if EnableNotarySupport has been successfully // called before. func (c *Client) IsNotaryEnabled() bool { return c.notary != nil } // ProbeNotary checks if native `Notary` contract is presented on chain. func (c *Client) ProbeNotary() (res bool) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return false } _, err := c.client.GetContractStateByAddressOrName(nativenames.Notary) return err == nil } // DepositNotary calls notary deposit method. Deposit is required to operate // with notary contract. It used by notary contract in to produce fallback tx // if main tx failed to create. Deposit isn't last forever, so it should // be called periodically. Notary support should be enabled in client to // use this function. // // This function must be invoked with notary enabled otherwise it throws panic. func (c *Client) DepositNotary(amount fixedn.Fixed8, delta uint32) (res util.Uint256, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return util.Uint256{}, ErrConnectionLost } if c.notary == nil { panic(notaryNotEnabledPanicMsg) } bc, err := c.rpcActor.GetBlockCount() if err != nil { return util.Uint256{}, fmt.Errorf("can't get blockchain height: %w", err) } currentTill, err := c.depositExpirationOf() if err != nil { return util.Uint256{}, fmt.Errorf("can't get previous expiration value: %w", err) } till := int64(bc + delta) if till < currentTill { till = currentTill } return c.depositNotary(amount, till) } // DepositEndlessNotary calls notary deposit method. Unlike `DepositNotary`, // this method sets notary deposit till parameter to a maximum possible value. // This allows to avoid ValidAfterDeposit failures. // // This function must be invoked with notary enabled otherwise it throws panic. func (c *Client) DepositEndlessNotary(amount fixedn.Fixed8) (res util.Uint256, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return util.Uint256{}, ErrConnectionLost } if c.notary == nil { panic(notaryNotEnabledPanicMsg) } // till value refers to a block height and it is uint32 value in neo-go return c.depositNotary(amount, math.MaxUint32) } func (c *Client) depositNotary(amount fixedn.Fixed8, till int64) (res util.Uint256, err error) { txHash, vub, err := c.gasToken.Transfer( c.accAddr, c.notary.notary, big.NewInt(int64(amount)), []any{c.acc.PrivateKey().GetScriptHash(), till}) if err != nil { if !errors.Is(err, neorpc.ErrAlreadyExists) { return util.Uint256{}, fmt.Errorf("can't make notary deposit: %w", err) } // Transaction is already in mempool waiting to be processed. // This is an expected situation if we restart the service. c.logger.Info(logs.ClientNotaryDepositHasAlreadyBeenMade, zap.Int64("amount", int64(amount)), zap.Int64("expire_at", till), zap.Uint32("vub", vub), zap.Error(err)) return util.Uint256{}, nil } c.logger.Info(logs.ClientNotaryDepositInvoke, zap.Int64("amount", int64(amount)), zap.Int64("expire_at", till), zap.Uint32("vub", vub), zap.Stringer("tx_hash", txHash.Reverse())) return txHash, nil } // GetNotaryDeposit returns deposit of client's account in notary contract. // Notary support should be enabled in client to use this function. // // This function must be invoked with notary enabled otherwise it throws panic. func (c *Client) GetNotaryDeposit() (res int64, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return 0, ErrConnectionLost } if c.notary == nil { panic(notaryNotEnabledPanicMsg) } sh := c.acc.PrivateKey().PublicKey().GetScriptHash() items, err := c.TestInvoke(c.notary.notary, notaryBalanceOfMethod, sh) if err != nil { return 0, fmt.Errorf("%v: %w", notaryBalanceErrMsg, err) } if len(items) != 1 { return 0, wrapFrostFSError(fmt.Errorf("%v: %w", notaryBalanceErrMsg, errUnexpectedItems)) } bigIntDeposit, err := items[0].TryInteger() if err != nil { return 0, wrapFrostFSError(fmt.Errorf("%v: %w", notaryBalanceErrMsg, err)) } return bigIntDeposit.Int64(), nil } // UpdateNotaryListPrm groups parameters of UpdateNotaryList operation. type UpdateNotaryListPrm struct { list keys.PublicKeys hash util.Uint256 } // SetList sets a list of the new notary role keys. func (u *UpdateNotaryListPrm) SetList(list keys.PublicKeys) { u.list = list } // SetHash sets hash of the transaction that led to the update // of the notary role in the designate contract. func (u *UpdateNotaryListPrm) SetHash(hash util.Uint256) { u.hash = hash } // UpdateNotaryList updates list of notary nodes in designate contract. Requires // committee multi signature. // // This function must be invoked with notary enabled otherwise it throws panic. func (c *Client) UpdateNotaryList(prm UpdateNotaryListPrm) error { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return ErrConnectionLost } if c.notary == nil { panic(notaryNotEnabledPanicMsg) } nonce, vub, err := c.CalculateNonceAndVUB(&prm.hash) if err != nil { return fmt.Errorf("could not calculate nonce and `valicUntilBlock` values: %w", err) } return c.notaryInvokeAsCommittee( setDesignateMethod, nonce, vub, noderoles.P2PNotary, prm.list, ) } // UpdateAlphabetListPrm groups parameters of UpdateNeoFSAlphabetList operation. type UpdateAlphabetListPrm struct { list keys.PublicKeys hash util.Uint256 } // SetList sets a list of the new alphabet role keys. func (u *UpdateAlphabetListPrm) SetList(list keys.PublicKeys) { u.list = list } // SetHash sets hash of the transaction that led to the update // of the alphabet role in the designate contract. func (u *UpdateAlphabetListPrm) SetHash(hash util.Uint256) { u.hash = hash } // UpdateNeoFSAlphabetList updates list of alphabet nodes in designate contract. // As for sidechain list should contain all inner ring nodes. // Requires committee multi signature. // // This function must be invoked with notary enabled otherwise it throws panic. func (c *Client) UpdateNeoFSAlphabetList(prm UpdateAlphabetListPrm) error { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return ErrConnectionLost } if c.notary == nil { panic(notaryNotEnabledPanicMsg) } nonce, vub, err := c.CalculateNonceAndVUB(&prm.hash) if err != nil { return fmt.Errorf("could not calculate nonce and `valicUntilBlock` values: %w", err) } return c.notaryInvokeAsCommittee( setDesignateMethod, nonce, vub, noderoles.NeoFSAlphabet, prm.list, ) } // NotaryInvoke invokes contract method by sending tx to notary contract in // blockchain. Fallback tx is a `RET`. If Notary support is not enabled // it fallbacks to a simple `Invoke()`. // // Returns valid until block value. // // `nonce` and `vub` are used only if notary is enabled. func (c *Client) NotaryInvoke(contract util.Uint160, fee fixedn.Fixed8, nonce uint32, vub *uint32, method string, args ...any) (uint32, error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return 0, ErrConnectionLost } if c.notary == nil { return c.Invoke(contract, fee, method, args...) } return c.notaryInvoke(false, true, contract, nonce, vub, method, args...) } // NotaryInvokeNotAlpha does the same as NotaryInvoke but does not use client's // private key in Invocation script. It means that main TX of notary request is // not expected to be signed by the current node. // // Considered to be used by non-IR nodes. func (c *Client) NotaryInvokeNotAlpha(contract util.Uint160, fee fixedn.Fixed8, vubP *uint32, method string, args ...any) (uint32, error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return 0, ErrConnectionLost } if c.notary == nil { return c.Invoke(contract, fee, method, args...) } return c.notaryInvoke(false, false, contract, rand.Uint32(), vubP, method, args...) } // NotarySignAndInvokeTX signs and sends notary request that was received from // Notary service. // NOTE: does not fallback to simple `Invoke()`. Expected to be used only for // TXs retrieved from the received notary requests. func (c *Client) NotarySignAndInvokeTX(mainTx *transaction.Transaction) error { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return ErrConnectionLost } alphabetList, err := c.notary.alphabetSource() if err != nil { return fmt.Errorf("could not fetch current alphabet keys: %w", err) } cosigners, err := c.notaryCosignersFromTx(mainTx, alphabetList) if err != nil { return err } nAct, err := notary.NewActor(c.client, cosigners, c.acc) if err != nil { return err } // Sign exactly the same transaction we've got from the received Notary request. err = nAct.Sign(mainTx) if err != nil { return fmt.Errorf("faield to sign notary request: %w", err) } mainH, fbH, untilActual, err := nAct.Notarize(mainTx, nil) if err != nil && !alreadyOnChainError(err) { return err } c.logger.Debug(logs.ClientNotaryRequestWithPreparedMainTXInvoked, zap.String("tx_hash", mainH.StringLE()), zap.Uint32("valid_until_block", untilActual), zap.String("fallback_hash", fbH.StringLE())) return nil } func (c *Client) notaryInvokeAsCommittee(method string, nonce, vub uint32, args ...any) error { designate := c.GetDesignateHash() _, err := c.notaryInvoke(true, true, designate, nonce, &vub, method, args...) return err } func (c *Client) notaryInvoke(committee, invokedByAlpha bool, contract util.Uint160, nonce uint32, vub *uint32, method string, args ...any) (uint32, error) { start := time.Now() success := false defer func() { c.metrics.ObserveInvoke("notaryInvoke", contract.String(), method, success, time.Since(start)) }() alphabetList, err := c.notary.alphabetSource() if err != nil { return 0, err } until, err := c.getUntilValue(vub) if err != nil { return 0, err } cosigners, err := c.notaryCosigners(invokedByAlpha, alphabetList, committee) if err != nil { return 0, err } nAct, err := notary.NewActor(c.client, cosigners, c.acc) if err != nil { return 0, err } mainH, fbH, untilActual, err := nAct.Notarize(nAct.MakeTunedCall(contract, method, nil, func(r *result.Invoke, t *transaction.Transaction) error { if r.State != vmstate.Halt.String() { return wrapFrostFSError(¬HaltStateError{state: r.State, exception: r.FaultException}) } t.ValidUntilBlock = until t.Nonce = nonce return nil }, args...)) if err != nil && !alreadyOnChainError(err) { return 0, err } c.logger.Debug(logs.ClientNotaryRequestInvoked, zap.String("method", method), zap.Uint32("valid_until_block", untilActual), zap.String("tx_hash", mainH.StringLE()), zap.String("fallback_hash", fbH.StringLE())) success = true return until, nil } func (c *Client) notaryCosignersFromTx(mainTx *transaction.Transaction, alphabetList keys.PublicKeys) ([]actor.SignerAccount, error) { multiaddrAccount, err := c.notaryMultisigAccount(alphabetList, false, true) if err != nil { return nil, err } // Here we need to add a committee signature (second witness) to the pre-validated // main transaction without creating a new one. However, Notary actor demands the // proper set of signers for constructor, thus, fill it from the main transaction's signers list. s := make([]actor.SignerAccount, 2, 3) s[0] = actor.SignerAccount{ // Proxy contract that will pay for the execution. Signer: mainTx.Signers[0], Account: notary.FakeContractAccount(mainTx.Signers[0].Account), } s[1] = actor.SignerAccount{ // Inner ring multisignature. Signer: mainTx.Signers[1], Account: multiaddrAccount, } if len(mainTx.Signers) > 3 { // Invoker signature (simple signature account of storage node is expected). var acc *wallet.Account script := mainTx.Scripts[2].VerificationScript if len(script) == 0 { acc = notary.FakeContractAccount(mainTx.Signers[2].Account) } else { pubBytes, ok := vm.ParseSignatureContract(script) if ok { pub, err := keys.NewPublicKeyFromBytes(pubBytes, elliptic.P256()) if err != nil { return nil, fmt.Errorf("failed to parse verification script of signer #2: invalid public key: %w", err) } acc = notary.FakeSimpleAccount(pub) } else { m, pubsBytes, ok := vm.ParseMultiSigContract(script) if !ok { return nil, errors.New("failed to parse verification script of signer #2: unknown witness type") } pubs := make(keys.PublicKeys, len(pubsBytes)) for i := range pubs { pubs[i], err = keys.NewPublicKeyFromBytes(pubsBytes[i], elliptic.P256()) if err != nil { return nil, fmt.Errorf("failed to parse verification script of signer #2: invalid public key #%d: %w", i, err) } } acc, err = notary.FakeMultisigAccount(m, pubs) if err != nil { return nil, fmt.Errorf("failed to create fake account for signer #2: %w", err) } } } s = append(s, actor.SignerAccount{ Signer: mainTx.Signers[2], Account: acc, }) } return s, nil } func (c *Client) notaryCosigners(invokedByAlpha bool, ir []*keys.PublicKey, committee bool) ([]actor.SignerAccount, error) { multiaddrAccount, err := c.notaryMultisigAccount(ir, committee, invokedByAlpha) if err != nil { return nil, err } s := make([]actor.SignerAccount, 2, 3) // Proxy contract that will pay for the execution. // Do not change this: // We must be able to call NNS contract indirectly from the Container contract. // Thus, CalledByEntry is not sufficient. // In future we may restrict this to all the usecases we have. scopes := transaction.Global if c.cfg.cmode != nil && c.cfg.cmode.Load() { // Set it to None to keep ability to send notary requests during upgrade scopes = transaction.None } s[0] = actor.SignerAccount{ Signer: transaction.Signer{ Account: c.notary.proxy, Scopes: scopes, }, Account: notary.FakeContractAccount(c.notary.proxy), } // Inner ring multisignature. s[1] = actor.SignerAccount{ Signer: transaction.Signer{ Account: multiaddrAccount.ScriptHash(), Scopes: c.cfg.signer.Scopes, AllowedContracts: c.cfg.signer.AllowedContracts, AllowedGroups: c.cfg.signer.AllowedGroups, }, Account: multiaddrAccount, } if !invokedByAlpha { // Invoker signature. s = append(s, actor.SignerAccount{ Signer: transaction.Signer{ Account: hash.Hash160(c.acc.GetVerificationScript()), Scopes: c.cfg.signer.Scopes, AllowedContracts: c.cfg.signer.AllowedContracts, AllowedGroups: c.cfg.signer.AllowedGroups, }, Account: c.acc, }) } // The last one is Notary contract that will be added to the signers list // by Notary actor automatically. return s, nil } func (c *Client) getUntilValue(vub *uint32) (uint32, error) { if vub != nil { return *vub, nil } return c.notaryTxValidationLimit() } func (c *Client) notaryMultisigAccount(ir []*keys.PublicKey, committee, invokedByAlpha bool) (*wallet.Account, error) { m := sigCount(ir, committee) var multisigAccount *wallet.Account var err error if invokedByAlpha { multisigAccount = wallet.NewAccountFromPrivateKey(c.acc.PrivateKey()) err := multisigAccount.ConvertMultisig(m, ir) if err != nil { // wrap error as FrostFS-specific since the call is not related to any client return nil, wrapFrostFSError(fmt.Errorf("can't convert account to inner ring multisig wallet: %w", err)) } } else { // alphabet multisig redeem script is // used as verification script for // inner ring multiaddress witness multisigAccount, err = notary.FakeMultisigAccount(m, ir) if err != nil { // wrap error as FrostFS-specific since the call is not related to any client return nil, wrapFrostFSError(fmt.Errorf("can't make inner ring multisig wallet: %w", err)) } } return multisigAccount, nil } func (c *Client) notaryTxValidationLimit() (uint32, error) { bc, err := c.rpcActor.GetBlockCount() if err != nil { return 0, fmt.Errorf("can't get current blockchain height: %w", err) } min := bc + c.notary.txValidTime rounded := (min/c.notary.roundTime + 1) * c.notary.roundTime return rounded, nil } func (c *Client) depositExpirationOf() (int64, error) { expirationRes, err := c.TestInvoke(c.notary.notary, notaryExpirationOfMethod, c.acc.PrivateKey().GetScriptHash()) if err != nil { return 0, fmt.Errorf("can't invoke method: %w", err) } if len(expirationRes) != 1 { return 0, fmt.Errorf("method returned unexpected item count: %d", len(expirationRes)) } currentTillBig, err := expirationRes[0].TryInteger() if err != nil { return 0, fmt.Errorf("can't parse deposit till value: %w", err) } return currentTillBig.Int64(), nil } // sigCount returns the number of required signature. // For FrostFS Alphabet M is a 2/3+1 of it (like in dBFT). // If committee is true, returns M as N/2+1. func sigCount(ir []*keys.PublicKey, committee bool) int { if committee { return sc.GetMajorityHonestNodeCount(len(ir)) } return sc.GetDefaultHonestNodeCount(len(ir)) } // WithTxValidTime returns a notary support option for client // that specifies minimum amount of blocks when mainTx will be valid. func WithTxValidTime(t uint32) NotaryOption { return func(c *notaryCfg) { c.txValidTime = t } } // WithRoundTime returns a notary support option for client // that specifies extra blocks to synchronize side chain // height diff of inner ring nodes. func WithRoundTime(t uint32) NotaryOption { return func(c *notaryCfg) { c.roundTime = t } } // WithAlphabetSource returns a notary support option for client // that specifies function to return list of alphabet node keys. // By default notary subsystem uses committee as a source. This is // valid for side chain but notary in main chain should override it. func WithAlphabetSource(t AlphabetKeys) NotaryOption { return func(c *notaryCfg) { c.alphabetSource = t } } // WithProxyContract sets proxy contract hash. func WithProxyContract(h util.Uint160) NotaryOption { return func(c *notaryCfg) { c.proxy = h } } // Neo RPC node can return `neorpc.ErrInvalidAttribute` error with // `conflicting transaction <> is already on chain` message. This // error is expected and ignored. As soon as main tx persisted on // chain everything is fine. This happens because notary contract // requires 5 out of 7 signatures to send main tx, thus last two // notary requests may be processed after main tx appeared on chain. func alreadyOnChainError(err error) bool { if !errors.Is(err, neorpc.ErrInvalidAttribute) { return false } const alreadyOnChainErrorMessage = "already on chain" return strings.Contains(err.Error(), alreadyOnChainErrorMessage) } // CalculateNotaryDepositAmount calculates notary deposit amount // using the rule: // // IF notaryBalance < gasBalance * gasMul { // DEPOSIT gasBalance / gasDiv // } ELSE { // DEPOSIT 1 // } // // gasMul and gasDiv must be positive. func CalculateNotaryDepositAmount(c *Client, gasMul, gasDiv int64) (fixedn.Fixed8, error) { notaryBalance, err := c.GetNotaryDeposit() if err != nil { return 0, fmt.Errorf("could not get notary balance: %w", err) } gasBalance, err := c.GasBalance() if err != nil { return 0, fmt.Errorf("could not get GAS balance: %w", err) } if gasBalance == 0 { return 0, errors.New("zero gas balance, nothing to deposit") } var depositAmount int64 if gasBalance*gasMul > notaryBalance { depositAmount = gasBalance / gasDiv } else { depositAmount = 1 } return fixedn.Fixed8(depositAmount), nil } // CalculateNonceAndVUB calculates nonce and ValidUntilBlock values // based on transaction hash. func (c *Client) CalculateNonceAndVUB(hash *util.Uint256) (nonce uint32, vub uint32, err error) { return c.calculateNonceAndVUB(hash, false) } // CalculateNonceAndVUBControl calculates nonce and rounded ValidUntilBlock values // based on transaction hash for use in control transactions. func (c *Client) CalculateNonceAndVUBControl(hash *util.Uint256) (nonce uint32, vub uint32, err error) { return c.calculateNonceAndVUB(hash, true) } // If hash specified, transaction's height and hash are used to compute VUB and nonce. // If not, then current block height used to compute VUB and nonce. func (c *Client) calculateNonceAndVUB(hash *util.Uint256, roundBlockHeight bool) (nonce uint32, vub uint32, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() if c.inactive { return 0, 0, ErrConnectionLost } if c.notary == nil { return 0, 0, nil } var height uint32 if hash != nil { height, err = c.getTransactionHeight(*hash) if err != nil { return 0, 0, fmt.Errorf("could not get transaction height: %w", err) } } else { height, err = c.rpcActor.GetBlockCount() if err != nil { return 0, 0, fmt.Errorf("could not get chain height: %w", err) } } // For control transactions, we round down the block height to control the // probability of all nodes producing the same transaction, since it depends // on this value. if roundBlockHeight { inc := c.rpcActor.GetVersion().Protocol.MaxValidUntilBlockIncrement height = height / inc * inc } if hash != nil { return binary.LittleEndian.Uint32(hash.BytesLE()), height + c.notary.txValidTime, nil } return height + c.notary.txValidTime, height + c.notary.txValidTime, nil } func (c *Client) getTransactionHeight(h util.Uint256) (uint32, error) { success := false startedAt := time.Now() defer func() { c.cache.metrics.AddMethodDuration("TxHeight", success, time.Since(startedAt)) }() if rh, ok := c.cache.txHeights.Get(h); ok { success = true return rh, nil } height, err := c.client.GetTransactionHeight(h) if err != nil { return 0, err } c.cache.txHeights.Add(h, height) success = true return height, nil }