From 8e16b1fd0b3efcda0c30e43218f9eeafc505932b Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Tue, 20 Jun 2023 16:46:20 +0400 Subject: [PATCH] Introduce procedure for the NeoFS Sidechain deployment There is a need to initialize running Neo network intended to be NeoFS Sidechain in automatic mode. To do this, a group of committee nodes working in the NeoFS Inner Ring should conduct a set of operations on the blockchain. This commit introduces partially implemented deployment procedure incl. NNS deployment, Notary service and committee group initialization. Deployment of NeoFS-system and custom smart contracts will be implemented in the future. Since procedure is incomplete, it's not yet used in the Inner Ring application. Refs #2195. Signed-off-by: Leonard Lyubich --- pkg/morph/deploy/deploy.go | 210 ++++++++ pkg/morph/deploy/group.go | 345 +++++++++++++ pkg/morph/deploy/group_test.go | 25 + pkg/morph/deploy/nns.go | 233 +++++++++ pkg/morph/deploy/notary.go | 872 +++++++++++++++++++++++++++++++++ pkg/morph/deploy/util.go | 168 +++++++ 6 files changed, 1853 insertions(+) create mode 100644 pkg/morph/deploy/deploy.go create mode 100644 pkg/morph/deploy/group.go create mode 100644 pkg/morph/deploy/group_test.go create mode 100644 pkg/morph/deploy/nns.go create mode 100644 pkg/morph/deploy/notary.go create mode 100644 pkg/morph/deploy/util.go diff --git a/pkg/morph/deploy/deploy.go b/pkg/morph/deploy/deploy.go new file mode 100644 index 00000000000..2e814674fcf --- /dev/null +++ b/pkg/morph/deploy/deploy.go @@ -0,0 +1,210 @@ +// Package deploy provides NeoFS Sidechain deployment functionality. +package deploy + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/nspcc-dev/neo-go/pkg/core/block" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/neorpc" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/nef" + "github.com/nspcc-dev/neo-go/pkg/wallet" + "go.uber.org/zap" +) + +// Blockchain groups services provided by particular Neo blockchain network +// representing NeoFS Sidechain that are required for its deployment. +type Blockchain interface { + // RPCActor groups functions needed to compose and send transactions to the + // blockchain. + actor.RPCActor + + // GetCommittee returns list of public keys owned by Neo blockchain committee + // members. Resulting list may be empty or repeating. + GetCommittee() (keys.PublicKeys, error) + + // GetContractStateByID returns network state of the smart contract by its ID. + // GetContractStateByID returns error with 'Unknown contract' substring if + // requested contract is missing. + GetContractStateByID(id int32) (*state.Contract, error) + + // ReceiveBlocks starts background process that forwards new blocks of the + // blockchain to the provided channel. The process handles all new blocks when + // ReceiveBlocks is called with nil filter. Returns unique identifier to be used + // to stop the process via Unsubscribe. + ReceiveBlocks(*neorpc.BlockFilter, chan<- *block.Block) (id string, err error) + + // Unsubscribe stops background process started by ReceiveBlocks by ID. + Unsubscribe(id string) error +} + +// KeyStorage represents storage of the private keys. +type KeyStorage interface { + // GetPersistedPrivateKey returns singleton private key persisted in the + // storage. GetPersistedPrivateKey randomizes the key initially. All subsequent + // successful calls return the same key. + GetPersistedPrivateKey() (*keys.PrivateKey, error) +} + +// CommonDeployPrm groups common deployment parameters of the smart contract. +type CommonDeployPrm struct { + NEF nef.File + Manifest manifest.Manifest +} + +// NNSPrm groups deployment parameters of the NeoFS NNS contract. +type NNSPrm struct { + Common CommonDeployPrm + SystemEmail string +} + +// Prm groups all parameters of the NeoFS Sidechain deployment procedure. +type Prm struct { + // Writes progress into the log. + Logger *zap.Logger + + // Particular Neo blockchain instance to be used as NeoFS Sidechain. + Blockchain Blockchain + + // Local process account used for transaction signing (must be unlocked). + LocalAccount *wallet.Account + + // Storage for single committee group key. + KeyStorage KeyStorage + + NNS NNSPrm +} + +// Deploy initializes Neo network represented by given Prm.Blockchain as NeoFS +// Sidechain and makes it full-featured for NeoFS storage system operation. +// +// Deploy aborts only by context or when a fatal error occurs. Deployment +// progress is logged in detail. It is expected that some situations can be +// changed/fixed on the chain from the outside, so Deploy adapts flexibly and +// does not stop at the moment. +// +// Deployment process is detailed in NeoFS docs. Summary of stages: +// 1. NNS contract deployment +// 2. launch of a notary service for the committee +// 3. committee group initialization +// 4. deployment of the NeoFS system contracts (currently not done) +// 5. deployment of custom contracts +// +// See project documentation for details. +func Deploy(ctx context.Context, prm Prm) error { + committee, err := prm.Blockchain.GetCommittee() + if err != nil { + return fmt.Errorf("get Neo committee of the network: %w", err) + } else if len(committee) == 0 { + return errors.New("empty Neo committee of the network") + } + + // just in case: docs don't deny, but we are tied to the number + committee = committee.Unique() + + sort.Sort(committee) + + // determine a leader + localPrivateKey := prm.LocalAccount.PrivateKey() + localPublicKey := localPrivateKey.PublicKey() + localAccCommitteeIndex := -1 + + for i := range committee { + if committee[i].Equal(localPublicKey) { + localAccCommitteeIndex = i + break + } + } + + if localAccCommitteeIndex < 0 { + return errors.New("local account does not belong to any Neo committee member") + } + + deployNNSPrm := deployNNSContractPrm{ + logger: prm.Logger, + blockchain: prm.Blockchain, + localAcc: prm.LocalAccount, + localNEF: prm.NNS.Common.NEF, + localManifest: prm.NNS.Common.Manifest, + systemEmail: prm.NNS.SystemEmail, + initCommitteeGroupKey: nil, // set below + } + + // if local node is the first committee member (Az) => deploy NNS contract, + // otherwise just wait + if localAccCommitteeIndex == 0 { + // Why such a centralized approach? There is a need to initialize committee + // contract group and share its private key between all committee members (the + // latter is done in the current procedure next). Currently, there is no + // convenient Neo service for this, and we don't want to use anything but + // blockchain, so the key is distributed through domain NNS records. However, + // then the chicken-and-egg problem pops up: committee group must be also set + // for the NNS contract. To set the group, you need to know the contract hash in + // advance, and it is a function from the sender of the deployment transaction. + // Summing up all these statements, we come to the conclusion that the one who + // deploys the contract creates the group key, and he shares it among the other + // members. Technically any committee member could deploy NNS contract, but for + // the sake of simplicity, this is a fixed node. This makes the procedure even + // more centralized, however, in practice, at the start of the network, all + // members are expected to be healthy and active. + // + // Note that manifest can't be changed w/o NEF change, so it's impossible to set + // committee group dynamically right after deployment. See + // https://github.com/nspcc-dev/neofs-contract/issues/340 + deployNNSPrm.initCommitteeGroupKey = prm.KeyStorage.GetPersistedPrivateKey + } + + prm.Logger.Info("initializing NNS contract on the chain...") + + nnsOnChainAddress, err := initNNSContract(ctx, deployNNSPrm) + if err != nil { + return fmt.Errorf("init NNS contract on the chain: %w", err) + } + + prm.Logger.Info("NNS contract successfully initialized on the chain", zap.Stringer("address", nnsOnChainAddress)) + + prm.Logger.Info("enable Notary service for the committee...") + + err = enableNotary(ctx, enableNotaryPrm{ + logger: prm.Logger, + blockchain: prm.Blockchain, + nnsOnChainAddress: nnsOnChainAddress, + systemEmail: prm.NNS.SystemEmail, + committee: committee, + localAcc: prm.LocalAccount, + localAccCommitteeIndex: localAccCommitteeIndex, + }) + if err != nil { + return fmt.Errorf("enable Notary service for the committee: %w", err) + } + + prm.Logger.Info("Notary service successfully enabled for the committee") + + prm.Logger.Info("initializing committee group for contract management...") + + committeeGroupKey, err := initCommitteeGroup(ctx, initCommitteeGroupPrm{ + logger: prm.Logger, + blockchain: prm.Blockchain, + nnsOnChainAddress: nnsOnChainAddress, + systemEmail: prm.NNS.SystemEmail, + committee: committee, + localAcc: prm.LocalAccount, + localAccCommitteeIndex: localAccCommitteeIndex, + keyStorage: prm.KeyStorage, + }) + if err != nil { + return fmt.Errorf("init committee group: %w", err) + } + + prm.Logger.Info("committee group successfully initialized", zap.Stringer("public key", committeeGroupKey.PublicKey())) + + // TODO: deploy contracts + + return nil +} diff --git a/pkg/morph/deploy/group.go b/pkg/morph/deploy/group.go new file mode 100644 index 00000000000..3e6caefe6e4 --- /dev/null +++ b/pkg/morph/deploy/group.go @@ -0,0 +1,345 @@ +package deploy + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/invoker" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/nns" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/wallet" + "go.uber.org/zap" +) + +// initCommitteeGroupPrm groups parameters of committee group initialization. +type initCommitteeGroupPrm struct { + logger *zap.Logger + + blockchain Blockchain + + nnsOnChainAddress util.Uint160 + systemEmail string + + committee keys.PublicKeys + localAcc *wallet.Account + localAccCommitteeIndex int + + keyStorage KeyStorage +} + +// initCommitteeGroup initializes committee group and returns corresponding private key. +func initCommitteeGroup(ctx context.Context, prm initCommitteeGroupPrm) (*keys.PrivateKey, error) { + monitor, err := newBlockchainMonitor(prm.logger, prm.blockchain) + if err != nil { + return nil, fmt.Errorf("init blockchain monitor: %w", err) + } + defer monitor.stop() + + inv := invoker.New(prm.blockchain, nil) + const leaderCommitteeIndex = 0 + var committeeGroupKey *keys.PrivateKey + var leaderTact func() + +upperLoop: + for recycle := false; ; recycle = true { + if recycle { + monitor.waitForNextBlock(ctx) + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("wait for committee group key to be distributed: %w", ctx.Err()) + default: + } + + prm.logger.Info("checking domains with shared committee group key...") + + nShared := 0 + + for i := range prm.committee { + domain := committeeGroupDomainForMember(i) + + rec, err := lookupNNSDomainRecord(inv, prm.nnsOnChainAddress, domain) + if err != nil { + if errors.Is(err, errMissingDomain) || errors.Is(err, errMissingDomainRecord) { + prm.logger.Info("NNS record with committee group key shared with the committee member is still missing, waiting...", + zap.String("domain", domain)) + } else { + prm.logger.Error("failed to lookup NNS domain record, will try again later", + zap.String("domain", domain), zap.Error(err)) + } + + continue + } + + if committeeGroupKey == nil && i == prm.localAccCommitteeIndex { + committeeGroupKey, err = decryptSharedPrivateKey(rec, prm.committee[leaderCommitteeIndex], prm.localAcc.PrivateKey()) + if err != nil { + prm.logger.Error("failed to decrypt shared committee group key, will wait for a background fix", + zap.String("domain", domain), zap.Error(err)) + continue upperLoop + } + } + + nShared++ + } + + if nShared == len(prm.committee) { + prm.logger.Info("committee group key is distributed between all committee members") + return committeeGroupKey, nil + } + + prm.logger.Info("not all committee members received the committee group key, distribution is needed", + zap.Int("need", len(prm.committee)), zap.Int("shared", nShared)) + + if prm.localAccCommitteeIndex != leaderCommitteeIndex { + prm.logger.Info("will wait for distribution from the leader") + continue + } + + if committeeGroupKey == nil { + committeeGroupKey, err = prm.keyStorage.GetPersistedPrivateKey() + if err != nil { + prm.logger.Error("failed to init committee group key, will try again later", zap.Error(err)) + continue + } + } + + if leaderTact == nil { + leaderTact, err = initShareCommitteeGroupKeyAsLeaderTact(prm, monitor, committeeGroupKey) + if err != nil { + prm.logger.Error("failed to construct action sharing committee group key between committee members as leader, will try again later", + zap.Error(err)) + continue + } + } + + leaderTact() + } +} + +// initShareCommitteeGroupKeyAsLeaderTact returns a function that preserves +// context of the committee group key distribution by leading committee member +// between calls. +func initShareCommitteeGroupKeyAsLeaderTact(prm initCommitteeGroupPrm, monitor *blockchainMonitor, committeeGroupKey *keys.PrivateKey) (func(), error) { + _actor, err := actor.NewSimple(prm.blockchain, prm.localAcc) + if err != nil { + return nil, fmt.Errorf("init transaction sender from local account: %w", err) + } + + _invoker := invoker.New(prm.blockchain, nil) + + // multi-tact context + mDomainsToVubs := make(map[string][2]uint32) // 1st - register, 2nd - addRecord + + return func() { + prm.logger.Info("distributing committee group key between committee members using NNS...") + + for i := range prm.committee { + domain := committeeGroupDomainForMember(i) + l := prm.logger.With(zap.String("domain", domain), zap.Stringer("member", prm.committee[i])) + + l.Info("synchronizing committee group key with NNS domain record...") + + _, err := lookupNNSDomainRecord(_invoker, prm.nnsOnChainAddress, domain) + if err != nil { + if errors.Is(err, errMissingDomain) { + l.Info("NNS domain is missing, registration is needed") + + vubs, ok := mDomainsToVubs[domain] + if ok && vubs[0] > 0 { + l.Info("transaction registering NNS domain was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= vubs[0] { + l.Info("previously sent transaction registering NNS domain may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", vubs[0])) + return + } + + l.Info("previously sent transaction registering NNS domain expired without side-effect") + } + + l.Info("sending new transaction registering domain in the NNS...") + + _, vub, err := _actor.SendCall(prm.nnsOnChainAddress, methodNNSRegister, + domain, _actor.Sender(), prm.systemEmail, nnsRefresh, nnsRetry, nnsExpire, nnsMinimum) + if err != nil { + vubs[0] = 0 + mDomainsToVubs[domain] = vubs + if isErrNotEnoughGAS(err) { + l.Info("not enough GAS to register domain in the NNS, will try again later") + } else { + l.Error("failed to send transaction registering domain in the NNS, will try again later", zap.Error(err)) + } + return + } + + vubs[0] = vub + mDomainsToVubs[domain] = vubs + + l.Info("transaction registering domain in the NNS has been successfully sent, will wait for the outcome") + + continue + } else if !errors.Is(err, errMissingDomainRecord) { + l.Error("failed to lookup NNS domain record, will try again later", zap.Error(err)) + return + } + + l.Info("missing record of the NNS domain, needed to be set") + + vubs, ok := mDomainsToVubs[domain] + if ok && vubs[1] > 0 { + l.Info("transaction setting NNS domain record was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= vubs[1] { + l.Info("previously sent transaction setting NNS domain record may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", vubs[1])) + return + } + + l.Info("previously sent transaction setting NNS domain record expired without side-effect") + } + + l.Info("sharing encrypted committee group key with the committee member...") + + keyCipher, err := encryptSharedPrivateKey(committeeGroupKey, prm.localAcc.PrivateKey(), prm.committee[i]) + if err != nil { + l.Error("failed to encrypt committee group key to share with the committee member, will try again later", + zap.Error(err)) + return + } + + l.Info("sending new transaction setting domain record in the NNS...") + + _, vub, err := _actor.SendCall(prm.nnsOnChainAddress, methodNNSAddRecord, + domain, int64(nns.TXT), keyCipher) + if err != nil { + vubs[1] = 0 + mDomainsToVubs[domain] = vubs + if isErrNotEnoughGAS(err) { + l.Info("not enough GAS to set NNS domain record, will try again later") + } else { + l.Error("failed to send transaction setting NNS domain record, will try again later", zap.Error(err)) + } + return + } + + vubs[1] = vub + mDomainsToVubs[domain] = vubs + + l.Info("transaction setting NNS domain record has been successfully sent, will wait for the outcome") + + continue + } + + l.Info("committee group key is shared with the committee member in NNS domain record") + } + }, nil +} + +// encryptSharedPrivateKey encrypts private key using provided coder's private +// key to be decrypted using decoder's private key. Inverse operation to +// decryptSharedPrivateKey. +func encryptSharedPrivateKey(sharedPrivKey, coderPrivKey *keys.PrivateKey, decoderPubKey *keys.PublicKey) (string, error) { + sharedSecret, err := calculateSharedSecret(coderPrivKey, decoderPubKey) + if err != nil { + return "", fmt.Errorf("calculate shared secret: %w", err) + } + + cipherBlock, err := aes.NewCipher(sharedSecret) + if err != nil { + return "", fmt.Errorf("create AES cipher block: %w", err) + } + + cipherMode, err := cipher.NewGCM(cipherBlock) + if err != nil { + return "", fmt.Errorf("wrap cipher block in GCM: %w", err) + } + + nonce := make([]byte, cipherMode.NonceSize()) + + _, err = rand.Reader.Read(nonce) + if err != nil { + return "", fmt.Errorf("generate nonce using crypto randomizer: %w", err) + } + + bKeyCipher, err := cipherMode.Seal(nonce, nonce, sharedPrivKey.Bytes(), nil), nil + if err != nil { + return "", fmt.Errorf("encrypt key binary: %w", err) + } + + return base64.StdEncoding.EncodeToString(bKeyCipher), nil +} + +// decryptSharedPrivateKey decrypts cipher of the private key encrypted by +// coder's private key. Inverse operation to encryptSharedPrivateKey. +func decryptSharedPrivateKey(sharedPrivKeyCipher string, coderPubKey *keys.PublicKey, decoderPrivKey *keys.PrivateKey) (*keys.PrivateKey, error) { + bKeyCipher, err := base64.StdEncoding.DecodeString(sharedPrivKeyCipher) + if err != nil { + return nil, fmt.Errorf("decode key cipher from base64: %w", err) + } + + sharedSecret, err := calculateSharedSecret(decoderPrivKey, coderPubKey) + if err != nil { + return nil, fmt.Errorf("calculate shared secret: %w", err) + } + + cipherBlock, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, fmt.Errorf("create AES cipher block: %w", err) + } + + cipherMode, err := cipher.NewGCM(cipherBlock) + if err != nil { + return nil, fmt.Errorf("wrap cipher block in GCM: %w", err) + } + + nonceSize := cipherMode.NonceSize() + if len(sharedPrivKeyCipher) < nonceSize { + return nil, fmt.Errorf("too short cipher %d", len(sharedPrivKeyCipher)) + } + + bSharedPrivKey, err := cipherMode.Open(nil, bKeyCipher[:nonceSize], bKeyCipher[nonceSize:], nil) + if err != nil { + return nil, fmt.Errorf("decrypt cipher: %w", err) + } + + sharedPrivKey, err := keys.NewPrivateKeyFromBytes(bSharedPrivKey) + if err != nil { + return nil, fmt.Errorf("decode key binary: %w", err) + } + + return sharedPrivKey, nil +} + +func calculateSharedSecret(localPrivKey *keys.PrivateKey, remotePubKey *keys.PublicKey) ([]byte, error) { + // this commented code will start working from go1.20 (it's fully compatible + // with current implementation) + // + // localPrivKeyECDH, err := localPrivKey.ECDH() + // if err != nil { + // return nil, fmt.Errorf("local private key to ECDH key: %w", err) + // } + // + // remotePubKeyECDH, err := (*ecdsa.PublicKey)(remotePubKey).ECDH() + // if err != nil { + // return nil, fmt.Errorf("remote public key to ECDH key: %w", err) + // } + // + // sharedSecret, err := localPrivKeyECDH.ECDH(remotePubKeyECDH) + // if err != nil { + // return nil, fmt.Errorf("ECDH exchange: %w", err) + // } + // + // return sharedSecret, nil + + x, _ := localPrivKey.ScalarMult(remotePubKey.X, remotePubKey.Y, localPrivKey.D.Bytes()) + return x.Bytes(), nil +} diff --git a/pkg/morph/deploy/group_test.go b/pkg/morph/deploy/group_test.go new file mode 100644 index 00000000000..9a30d330995 --- /dev/null +++ b/pkg/morph/deploy/group_test.go @@ -0,0 +1,25 @@ +package deploy + +import ( + "testing" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/stretchr/testify/require" +) + +func TestKeySharing(t *testing.T) { + coderKey, err := keys.NewPrivateKey() + require.NoError(t, err) + decoderKey, err := keys.NewPrivateKey() + require.NoError(t, err) + sharedKey, err := keys.NewPrivateKey() + require.NoError(t, err) + + cipher, err := encryptSharedPrivateKey(sharedKey, coderKey, decoderKey.PublicKey()) + require.NoError(t, err) + + restoredKey, err := decryptSharedPrivateKey(cipher, coderKey.PublicKey(), decoderKey) + require.NoError(t, err) + + require.Equal(t, sharedKey, restoredKey) +} diff --git a/pkg/morph/deploy/nns.go b/pkg/morph/deploy/nns.go new file mode 100644 index 00000000000..be238a86cd5 --- /dev/null +++ b/pkg/morph/deploy/nns.go @@ -0,0 +1,233 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/invoker" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/management" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/nns" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/unwrap" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/nef" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/nspcc-dev/neo-go/pkg/wallet" + "go.uber.org/zap" +) + +// various NNS domain names. +const ( + domainBootstrap = "bootstrap" + domainDesignateNotaryPrefix = "designate-committee-notary-" + domainDesignateNotaryTx = domainDesignateNotaryPrefix + "tx." + domainBootstrap + domainContractAddresses = "neofs" +) + +func designateNotarySignatureDomainForMember(memberIndex int) string { + return fmt.Sprintf("%s%d.%s", domainDesignateNotaryPrefix, memberIndex, domainBootstrap) +} + +func committeeGroupDomainForMember(memberIndex int) string { + return fmt.Sprintf("committee-group-%d.%s", memberIndex, domainBootstrap) +} + +// various methods of the NeoFS NNS contract. +const ( + methodNNSRegister = "register" + methodNNSResolve = "resolve" + methodNNSAddRecord = "addRecord" + methodNNSSetRecord = "setRecord" +) + +// default NNS domain settings. See DNS specification and also +// https://www.ripe.net/publications/docs/ripe-203. +const ( + nnsRefresh = 3600 + nnsRetry = 600 + nnsExpire = int64(10 * 365 * 24 * time.Hour / time.Second) + nnsMinimum = 3600 +) + +// various NNS errors. +var ( + errMissingDomain = errors.New("missing domain") + errMissingDomainRecord = errors.New("missing domain record") +) + +// deployNNSContractPrm groups parameters of NeoFS NNS contract deployment. +type deployNNSContractPrm struct { + logger *zap.Logger + + blockchain Blockchain + + localAcc *wallet.Account + + localNEF nef.File + localManifest manifest.Manifest + systemEmail string + + // optional constructor of private key for the committee group. If set, it is + // used only when contract is missing. + initCommitteeGroupKey func() (*keys.PrivateKey, error) +} + +// initNNSContract synchronizes NNS contract with the chain and returns the +// address. Success is the presence of NNS contract in the chain with ID=1. +// initNNSContract returns any error encountered due to which the contract +// cannot be synchronized in any way. For example, problems that can be fixed on +// the chain in the background (manually or automatically over time) do not stop +// the procedure. Breaking the context stops execution immediately (so hangup is +// not possible) and the function returns an error. In this case, +// initNNSContract can be re-called (expected after application restart): all +// previously succeeded actions will be skipped, and execution will be continued +// from the last failed stage. +// +// If contract is missing and deployNNSContractPrm.initCommitteeGroupKey is provided, +// initNNSContract attempts to deploy local contract. +func initNNSContract(ctx context.Context, prm deployNNSContractPrm) (res util.Uint160, err error) { + monitor, err := newBlockchainMonitor(prm.logger, prm.blockchain) + if err != nil { + return res, fmt.Errorf("init blockchain monitor: %w", err) + } + defer monitor.stop() + + var managementContract *management.Contract + var sentTxValidUntilBlock uint32 + var committeeGroupKey *keys.PrivateKey + + for recycle := false; ; recycle = true { + if recycle { + monitor.waitForNextBlock(ctx) + } + + select { + case <-ctx.Done(): + return res, fmt.Errorf("wait for NNS contract synchronization: %w", ctx.Err()) + default: + } + + prm.logger.Info("reading on-chain state of the NNS contract by ID=1") + + stateOnChain, err := readNNSOnChainState(prm.blockchain) + if err != nil { + prm.logger.Error("failed to read on-chain state of the NNS contract, will try again later", zap.Error(err)) + continue + } else if stateOnChain != nil { + // declared in https://github.com/nspcc-dev/neofs-contract sources + const nnsContractName = "NameService" + if stateOnChain.Manifest.Name != nnsContractName { + return res, fmt.Errorf("wrong name of the contract with ID=1: expected '%s', got '%s'", + nnsContractName, stateOnChain.Manifest.Name) + } + + return stateOnChain.Hash, nil + } + + if prm.initCommitteeGroupKey == nil { + prm.logger.Info("NNS contract is missing on the chain but attempts to deploy are disabled, will wait for background deployment") + continue + } + + prm.logger.Info("NNS contract is missing on the chain, contract needs to be deployed") + + if committeeGroupKey == nil { + prm.logger.Info("initializing private key for the committee group...") + + committeeGroupKey, err = prm.initCommitteeGroupKey() + if err != nil { + prm.logger.Error("failed to init committee group key, will try again later", zap.Error(err)) + continue + } + + prm.logger.Info("private key of the committee group has been initialized", zap.Stringer("public key", committeeGroupKey.PublicKey())) + } + + if sentTxValidUntilBlock > 0 { + prm.logger.Info("transaction deploying NNS contract was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= sentTxValidUntilBlock { + prm.logger.Info("previously sent transaction deploying NNS contract may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", sentTxValidUntilBlock)) + continue + } + + prm.logger.Info("previously sent transaction deploying NNS contract expired without side-effect") + } + + prm.logger.Info("sending new transaction deploying NNS contract...") + + if managementContract == nil { + _actor, err := actor.NewSimple(prm.blockchain, prm.localAcc) + if err != nil { + prm.logger.Warn("NNS contract is missing on the chain but attempts to deploy are disabled, will try again later") + continue + } + + managementContract = management.New(_actor) + + setGroupInManifest(&prm.localManifest, prm.localNEF, committeeGroupKey, prm.localAcc.ScriptHash()) + } + + // just to definitely avoid mutation + nefCp := prm.localNEF + manifestCp := prm.localManifest + + _, vub, err := managementContract.Deploy(&nefCp, &manifestCp, []interface{}{ + []interface{}{ + []interface{}{domainBootstrap, prm.systemEmail}, + []interface{}{domainContractAddresses, prm.systemEmail}, + }, + }) + if err != nil { + sentTxValidUntilBlock = 0 + if isErrNotEnoughGAS(err) { + prm.logger.Info("not enough GAS to deploy NNS contract, will try again later") + } else { + prm.logger.Error("failed to send transaction deploying NNS contract, will try again later", zap.Error(err)) + } + continue + } + + sentTxValidUntilBlock = vub + + prm.logger.Info("transaction deploying NNS contract has been successfully sent, will wait for the outcome") + } +} + +// lookupNNSDomainRecord looks up for the 1st record of the NNS domain with +// given name. Returns errMissingDomain is domain doesn't exist. Returns +// errMissingDomainRecord domain has no records. +func lookupNNSDomainRecord(inv *invoker.Invoker, nnsContract util.Uint160, domainName string) (string, error) { + item, err := unwrap.Item(inv.Call(nnsContract, methodNNSResolve, domainName, int64(nns.TXT))) + if err != nil { + if strings.Contains(err.Error(), "token not found") { + return "", errMissingDomain + } + + return "", fmt.Errorf("call '%s' method of the NNS contract: %w", methodNNSResolve, err) + } + + arr, ok := item.Value().([]stackitem.Item) + if !ok { + if _, ok = item.(stackitem.Null); !ok { + return "", fmt.Errorf("malformed/unsupported response of the NNS '%s' method: expected array, got %s", + methodNNSResolve, item.Type()) + } + } else if len(arr) > 0 { + b, err := arr[0].TryBytes() + if err != nil { + return "", fmt.Errorf("malformed/unsupported 1st array item of the NNS '%s' method response (expected %v): %w", + methodNNSResolve, stackitem.ByteArrayT, err) + } + + return string(b), nil + } + + return "", errMissingDomainRecord +} diff --git a/pkg/morph/deploy/notary.go b/pkg/morph/deploy/notary.go new file mode 100644 index 00000000000..844203f7e69 --- /dev/null +++ b/pkg/morph/deploy/notary.go @@ -0,0 +1,872 @@ +package deploy + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + + "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/rpcclient/actor" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/invoker" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/nns" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/notary" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/rolemgmt" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/opcode" + "github.com/nspcc-dev/neo-go/pkg/wallet" + randutil "github.com/nspcc-dev/neofs-node/pkg/util/rand" + "go.uber.org/zap" +) + +// enableNotaryPrm groups parameters of Notary service initialization parameters +// for the committee. +type enableNotaryPrm struct { + logger *zap.Logger + + blockchain Blockchain + + nnsOnChainAddress util.Uint160 + systemEmail string + + committee keys.PublicKeys + localAcc *wallet.Account + localAccCommitteeIndex int +} + +// enableNotary makes Notary service ready-to-go for the committee members. +func enableNotary(ctx context.Context, prm enableNotaryPrm) error { + monitor, err := newBlockchainMonitor(prm.logger, prm.blockchain) + if err != nil { + return fmt.Errorf("init blockchain monitor: %w", err) + } + defer monitor.stop() + + var tact func() + roleContract := rolemgmt.NewReader(invoker.New(prm.blockchain, nil)) + + for recycle := false; ; recycle = true { + if recycle { + monitor.waitForNextBlock(ctx) + } + + select { + case <-ctx.Done(): + return fmt.Errorf("wait for Notary service to be enabled for the committee: %w", ctx.Err()) + default: + } + + prm.logger.Info("checking Notary role of the committee members...") + + accsWithNotaryRole, err := roleContract.GetDesignatedByRole(noderoles.P2PNotary, monitor.currentHeight()) + if err != nil { + prm.logger.Error("failed to check role of the committee, will try again later", zap.Error(err)) + continue + } + + someoneWithoutNotaryRole := len(accsWithNotaryRole) < len(prm.committee) + if !someoneWithoutNotaryRole { + for i := range prm.committee { + if !accsWithNotaryRole.Contains(prm.committee[i]) { + someoneWithoutNotaryRole = true + break + } + } + } + if !someoneWithoutNotaryRole { + prm.logger.Info("all committee members have a Notary role") + return nil + } + + prm.logger.Info("not all members of the committee have a Notary role, designation is needed") + + if tact == nil { + if len(prm.committee) == 1 { + prm.logger.Info("committee is single-acc, no multi-signature needed for Notary role designation") + + tact, err = initDesignateNotaryRoleToLocalAccountTact(prm, monitor) + if err != nil { + prm.logger.Error("failed to construct action designating Notary role to the local account, will try again later", + zap.Error(err)) + continue + } + } else { + prm.logger.Info("committee is multi-acc, multi-signature is needed for Notary role designation") + + if prm.localAccCommitteeIndex == 0 { + tact, err = initDesignateNotaryRoleAsLeaderTact(prm, monitor) + if err != nil { + prm.logger.Error("failed to construct action designating Notary role to the multi-acc committee as leader, will try again later", + zap.Error(err)) + continue + } + } else { + tact, err = initDesignateNotaryRoleAsSignerTact(prm, monitor) + if err != nil { + prm.logger.Error("failed to construct action designating Notary role to the multi-acc committee as signer, will try again later", + zap.Error(err)) + continue + } + } + } + } + + tact() + } +} + +// initDesignateNotaryRoleToLocalAccountTact returns a function that preserves +// context of the Notary role designation to the local account between calls. +func initDesignateNotaryRoleToLocalAccountTact(prm enableNotaryPrm, monitor *blockchainMonitor) (func(), error) { + _actor, err := actor.NewSimple(prm.blockchain, prm.localAcc) + if err != nil { + return nil, fmt.Errorf("init transaction sender from local account: %w", err) + } + + roleContract := rolemgmt.New(_actor) + + // multi-tact context + var sentTxValidUntilBlock uint32 + + return func() { + if sentTxValidUntilBlock > 0 && sentTxValidUntilBlock <= monitor.currentHeight() { + prm.logger.Info("previously sent transaction designating Notary role to the local account may still be relevant, will wait for the outcome") + return + } + + if sentTxValidUntilBlock > 0 { + prm.logger.Info("transaction designating Notary role to the local account was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= sentTxValidUntilBlock { + prm.logger.Info("previously sent transaction designating Notary role to the local account may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", sentTxValidUntilBlock)) + return + } + + prm.logger.Info("previously sent transaction designating Notary role to the local account expired without side-effect") + } + + prm.logger.Info("sending new transaction designating Notary role to the local account...") + + var err error + + _, vub, err := roleContract.DesignateAsRole(noderoles.P2PNotary, keys.PublicKeys{prm.localAcc.PublicKey()}) + if err != nil { + sentTxValidUntilBlock = 0 + if isErrNotEnoughGAS(err) { + prm.logger.Info("not enough GAS to designate Notary role to the local account, will try again later") + } else { + prm.logger.Error("failed to send transaction designating Notary role to the local account, will try again later", zap.Error(err)) + } + return + } + + sentTxValidUntilBlock = vub + + prm.logger.Info("transaction designating Notary role to the local account has been successfully sent, will wait for the outcome") + }, nil +} + +// initDesignateNotaryRoleAsLeaderTact returns a function that preserves context +// of the Notary role designation to the multi-acc committee between calls. The +// operation is performed by the leading committee member which is assigned to +// collect signatures for the corresponding transaction. +func initDesignateNotaryRoleAsLeaderTact(prm enableNotaryPrm, monitor *blockchainMonitor) (func(), error) { + committeeMultiSigM := smartcontract.GetMajorityHonestNodeCount(len(prm.committee)) + committeeMultiSigAcc := wallet.NewAccountFromPrivateKey(prm.localAcc.PrivateKey()) + + err := committeeMultiSigAcc.ConvertMultisig(committeeMultiSigM, prm.committee) + if err != nil { + return nil, fmt.Errorf("compose committee multi-signature account: %w", err) + } + + _actor, err := actor.NewSimple(prm.blockchain, prm.localAcc) + if err != nil { + return nil, fmt.Errorf("init transaction sender from local account: %w", err) + } + + committeeSigners := []actor.SignerAccount{ + { + Signer: transaction.Signer{ + Account: prm.localAcc.ScriptHash(), + Scopes: transaction.CalledByEntry, + }, + Account: prm.localAcc, + }, + { + Signer: transaction.Signer{ + Account: committeeMultiSigAcc.ScriptHash(), + Scopes: transaction.CustomGroups | transaction.CalledByEntry, + }, + Account: committeeMultiSigAcc, + }, + } + + committeeActor, err := actor.New(prm.blockchain, committeeSigners) + if err != nil { + return nil, fmt.Errorf("init transaction sender with committee signers: %w", err) + } + + _invoker := invoker.New(prm.blockchain, nil) + roleContract := rolemgmt.New(committeeActor) + + // multi-tact context + var registerDomainTxValidUntilBlock uint32 + var setDomainRecordTxValidUntilBlock uint32 + var tx *transaction.Transaction + var mCommitteeIndexToSignature map[int][]byte + var designateRoleTxValidUntilBlock uint32 + var txFullySigned bool + + resetTx := func() { + tx = nil + setDomainRecordTxValidUntilBlock = 0 + for k := range mCommitteeIndexToSignature { + delete(mCommitteeIndexToSignature, k) + } + designateRoleTxValidUntilBlock = 0 + txFullySigned = false + } + + return func() { + l := prm.logger.With(zap.String("domain", domainDesignateNotaryTx)) + + l.Info("synchronizing shared data of the transaction designating Notary role to the committee with NNS domain record...") + + var sharedTxData sharedTransactionData + var recordExists bool + var needReset bool + + strSharedTxData, err := lookupNNSDomainRecord(_invoker, prm.nnsOnChainAddress, domainDesignateNotaryTx) + if err != nil { + if errors.Is(err, errMissingDomain) { + l.Info("NNS domain is missing, registration is needed") + + if registerDomainTxValidUntilBlock > 0 { + l.Info("transaction registering NNS domain was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= registerDomainTxValidUntilBlock { + l.Info("previously sent transaction registering NNS domain may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", registerDomainTxValidUntilBlock)) + return + } + + l.Info("previously sent transaction registering NNS domain expired without side-effect") + } + + l.Info("sending new transaction registering domain in the NNS...") + + _, vub, err := _actor.SendCall(prm.nnsOnChainAddress, methodNNSRegister, + domainDesignateNotaryTx, _actor.Sender(), prm.systemEmail, nnsRefresh, nnsRetry, nnsExpire, nnsMinimum) + if err != nil { + registerDomainTxValidUntilBlock = 0 + if isErrNotEnoughGAS(err) { + prm.logger.Info("not enough GAS to register domain in the NNS, will try again later") + } else { + prm.logger.Error("failed to send transaction registering domain in the NNS, will try again later", zap.Error(err)) + } + return + } + + registerDomainTxValidUntilBlock = vub + + l.Info("transaction registering domain in the NNS has been successfully sent, will wait for the outcome") + + return + } else if !errors.Is(err, errMissingDomainRecord) { + l.Error("failed to lookup NNS domain record, will try again later", zap.Error(err)) + return + } + + l.Info("missing record of the NNS domain, needed to be set") + + if setDomainRecordTxValidUntilBlock > 0 { + l.Info("transaction setting NNS domain record was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= setDomainRecordTxValidUntilBlock { + l.Info("previously sent transaction setting NNS domain record may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", setDomainRecordTxValidUntilBlock)) + return + } + + l.Info("previously sent transaction setting NNS domain record expired without side-effect") + } + + needReset = true + } else { + err = sharedTxData.decodeString(strSharedTxData) + if err != nil { + l.Error("failed to decode shared data of the transaction got from the NNS domain record, will wait for a background fix", + zap.Error(err)) + return + } + + if cur := monitor.currentHeight(); cur > sharedTxData.validUntilBlock { + l.Error("previously used shared data of the transaction expired, need a reset", + zap.Uint32("expires after height", sharedTxData.validUntilBlock), zap.Uint32("current height", cur)) + resetTx() + needReset = true + } + + recordExists = true + } + + if needReset { + prm.logger.Info("generating shared data for the transaction designating Notary role to the committee...") + + txVub, err := _actor.CalculateValidUntilBlock() + if err != nil { + prm.logger.Info("failed to calculated ValidUntilBlock parameter for transaction designating Notary role to the committee, will try again later", + zap.Error(err)) + return + } + + strSharedTxData = sharedTransactionData{ + sender: _actor.Sender(), + validUntilBlock: txVub, + nonce: randutil.Uint32(), + }.encodeToString() + + l.Info("sending new transaction setting domain record in the NNS...") + + var vub uint32 + if recordExists { + _, vub, err = _actor.SendCall(prm.nnsOnChainAddress, methodNNSSetRecord, + domainDesignateNotaryTx, int64(nns.TXT), 0, strSharedTxData) + } else { + _, vub, err = _actor.SendCall(prm.nnsOnChainAddress, methodNNSAddRecord, + domainDesignateNotaryTx, int64(nns.TXT), strSharedTxData) + } + if err != nil { + setDomainRecordTxValidUntilBlock = 0 + if isErrNotEnoughGAS(err) { + prm.logger.Info("not enough GAS to set NNS domain record, will try again later") + } else { + prm.logger.Error("failed to send transaction setting NNS domain record, will try again later", zap.Error(err)) + } + return + } + + setDomainRecordTxValidUntilBlock = vub + + l.Info("transaction setting NNS domain record has been successfully sent, will wait for the outcome") + + return + } + + l.Info("shared data of the transaction designating Notary role to the committee synchronized successfully", + zap.Uint32("nonce", sharedTxData.nonce), zap.Uint32("expires after height", sharedTxData.validUntilBlock), + zap.Stringer("sender", sharedTxData.sender), + ) + + if tx == nil || !sharedTxDataMatches(tx, sharedTxData) { + prm.logger.Info("making new transaction designating Notary role to the committee...") + + tx, err = makeUnsignedDesignateCommitteeNotaryTx(roleContract, prm.committee, sharedTxData) + if err != nil { + prm.logger.Error("failed to make unsigned transaction designating Notary role to the committee, will try again later", + zap.Error(err)) + return + } + + prm.logger.Info("transaction designating Notary role to the committee initialized, signing...") + + netMagic := _actor.GetNetwork() + + err = prm.localAcc.SignTx(netMagic, tx) + if err != nil { + prm.logger.Error("failed to sign transaction designating Notary role to the committee by local node's account, will try again later", + zap.Error(err)) + return + } + + err = committeeMultiSigAcc.SignTx(netMagic, tx) + if err != nil { + prm.logger.Error("failed to sign transaction designating Notary role to the committee by committee multi-signature account, will try again later", + zap.Error(err)) + return + } + + prm.logger.Info("new transaction designating Notary role to the committee successfully made") + } else { + prm.logger.Info("previously made transaction designating Notary role to the committee is still relevant, continue with it") + } + + needRemoteSignatures := committeeMultiSigM - 1 // -1 local, we always have it + + if len(mCommitteeIndexToSignature) < needRemoteSignatures { + if mCommitteeIndexToSignature == nil { + mCommitteeIndexToSignature = make(map[int][]byte, needRemoteSignatures) + } + + prm.logger.Info("collecting signatures of the transaction designating notary role to the committee from other members using NNS...") + + for i := range prm.committee[1:] { + domain := designateNotarySignatureDomainForMember(i) + + rec, err := lookupNNSDomainRecord(_invoker, prm.nnsOnChainAddress, domain) + if err != nil { + if errors.Is(err, errMissingDomain) || errors.Is(err, errMissingDomainRecord) { + prm.logger.Info("missing NNS domain record with committee member's signature of the transaction designating Notary role to the committee, will wait", + zap.Stringer("member", prm.committee[i]), + zap.String("domain", domain)) + } else { + prm.logger.Error("failed to read NNS domain record with committee member's signature of the transaction designating Notary role to the committee, will try again later", + zap.Stringer("member", prm.committee[i]), + zap.String("domain", domain), + zap.Error(err)) + } + continue + } + + bRec, err := base64.StdEncoding.DecodeString(rec) + if err != nil { + prm.logger.Info("failed to decode NNS domain record with committee member's signature of the transaction designating Notary role to the committee from base64, will wait for a background fix", + zap.Stringer("member", prm.committee[i]), + zap.String("domain", domain), + zap.Error(err)) + continue + } + + checksumMatches, bSignature := sharedTxData.shiftChecksum(bRec) + if !checksumMatches { + prm.logger.Info("checksum of shared data of the transaction designating Notary role to the committee submitted by committee member mismatches, skip signature", + zap.Stringer("member", prm.committee[i]), + zap.String("domain", domain)) + continue + } + + txCp := *tx // to safely call Hash method below + if !prm.committee[i].VerifyHashable(bSignature, uint32(_actor.GetNetwork()), &txCp) { + prm.logger.Info("invalid signature of the transaction designating Notary role to the committee submitted by committee member, skip", + zap.Stringer("member", prm.committee[i]), + zap.String("domain", domain)) + continue + } + + mCommitteeIndexToSignature[i] = bSignature + if len(mCommitteeIndexToSignature) == needRemoteSignatures { + break + } + } + + if len(mCommitteeIndexToSignature) < needRemoteSignatures { + prm.logger.Info("there are still not enough signatures of the transaction designating Notary role to the committee in the NNS, will wait", + zap.Int("need", needRemoteSignatures), zap.Int("got", len(mCommitteeIndexToSignature))) + return + } + } + + prm.logger.Info("gathered enough signatures of the transaction designating Notary role to the committee") + + if designateRoleTxValidUntilBlock > 0 { + prm.logger.Info("transaction designating Notary role to the committee was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= designateRoleTxValidUntilBlock { + prm.logger.Info("previously sent transaction designating Notary role to the committee may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", designateRoleTxValidUntilBlock)) + return + } + + prm.logger.Info("previously sent transaction designating Notary role to the committee expired without side-effect, will recreate") + resetTx() + return + } + + if !txFullySigned { + prm.logger.Info("finalizing the transaction designating Notary role to the committee...") + + initialLen := len(tx.Scripts[1].InvocationScript) + var extraLen int + + for _, sig := range mCommitteeIndexToSignature { + extraLen += 1 + 1 + len(sig) // opcode + length + value + } + + tx.Scripts[1].InvocationScript = append(tx.Scripts[1].InvocationScript, + make([]byte, extraLen)...) + buf := tx.Scripts[1].InvocationScript[initialLen:] + + for _, sig := range mCommitteeIndexToSignature { + buf[0] = byte(opcode.PUSHDATA1) + buf[1] = byte(len(sig)) + buf = buf[2:] + buf = buf[copy(buf, sig):] + } + + txFullySigned = true + } + + prm.logger.Info("sending the transaction designating Notary role to the committee...") + + _, vub, err := _actor.Send(tx) + if err != nil { + designateRoleTxValidUntilBlock = 0 + switch { + default: + prm.logger.Error("failed to send transaction designating Notary role to the committee, will try again later", + zap.Error(err)) + case isErrNotEnoughGAS(err): + prm.logger.Info("not enough GAS for transaction designating Notary role to the committee, will try again later") + case isErrTransactionExpired(err): + resetTx() + } + return + } + + designateRoleTxValidUntilBlock = vub + + prm.logger.Info("transaction designating Notary role to the committee has been successfully sent, will wait for the outcome") + }, nil +} + +// initDesignateNotaryRoleAsSignerTact returns a function that preserves context +// of the Notary role designation to the multi-acc committee between calls. The +// operation is performed by the non-leading committee member which is assigned to +// sign transaction submitted by the leader. +func initDesignateNotaryRoleAsSignerTact(prm enableNotaryPrm, monitor *blockchainMonitor) (func(), error) { + committeeMultiSigM := smartcontract.GetMajorityHonestNodeCount(len(prm.committee)) + committeeMultiSigAcc := wallet.NewAccountFromPrivateKey(prm.localAcc.PrivateKey()) + + err := committeeMultiSigAcc.ConvertMultisig(committeeMultiSigM, prm.committee) + if err != nil { + return nil, fmt.Errorf("compose committee multi-signature account: %w", err) + } + + _actor, err := actor.NewSimple(prm.blockchain, prm.localAcc) + if err != nil { + return nil, fmt.Errorf("init transaction sender from local account: %w", err) + } + + committeeSigners := []actor.SignerAccount{ + { + Signer: transaction.Signer{ + Account: prm.localAcc.ScriptHash(), + Scopes: transaction.CalledByEntry, + }, + Account: prm.localAcc, + }, + { + Signer: transaction.Signer{ + Account: committeeMultiSigAcc.ScriptHash(), + Scopes: transaction.CustomGroups | transaction.CalledByEntry, + }, + Account: committeeMultiSigAcc, + }, + } + + committeeActor, err := actor.New(prm.blockchain, committeeSigners) + if err != nil { + return nil, fmt.Errorf("init transaction sender with committee signers: %w", err) + } + + _invoker := invoker.New(prm.blockchain, nil) + roleContract := rolemgmt.New(committeeActor) + + // multi-tact context + var tx *transaction.Transaction + var registerDomainTxValidUntilBlock uint32 + var setDomainRecordTxValidUntilBlock uint32 + + resetTx := func() { + tx = nil + setDomainRecordTxValidUntilBlock = 0 + } + + return func() { + l := prm.logger.With(zap.String("domain", domainDesignateNotaryTx)) + + prm.logger.Info("synchronizing shared data of the transaction designating Notary role to the committee with NNS domain record...") + + strSharedTxData, err := lookupNNSDomainRecord(_invoker, prm.nnsOnChainAddress, domainDesignateNotaryTx) + if err != nil { + switch { + default: + l.Error("failed to lookup NNS domain record, will try again later", zap.Error(err)) + case errors.Is(err, errMissingDomain): + l.Info("NNS domain is missing, will wait for a leader") + case errors.Is(err, errMissingDomainRecord): + l.Info("missing record in the NNS domain, will wait for a leader") + } + return + } + + var sharedTxData sharedTransactionData + + err = sharedTxData.decodeString(strSharedTxData) + if err != nil { + l.Error("failed to decode shared data of the transaction got from the NNS domain record, will wait for a background fix", + zap.Error(err)) + return + } + + if cur := monitor.currentHeight(); cur > sharedTxData.validUntilBlock { + l.Error("previously used shared data of the transaction expired, will wait for update by leader", + zap.Uint32("expires after height", sharedTxData.validUntilBlock), zap.Uint32("current height", cur)) + resetTx() + return + } + + l.Info("shared data of the transaction designating Notary role to the committee synchronized successfully", + zap.Uint32("nonce", sharedTxData.nonce), zap.Uint32("expires after height", sharedTxData.validUntilBlock), + zap.Stringer("sender", sharedTxData.sender), + ) + + if tx == nil || !sharedTxDataMatches(tx, sharedTxData) { + prm.logger.Info("recreating the transaction designating Notary role to the committee...") + + tx, err = makeUnsignedDesignateCommitteeNotaryTx(roleContract, prm.committee, sharedTxData) + if err != nil { + prm.logger.Error("failed to make unsigned transaction designating Notary role to the committee, will try again later", + zap.Error(err)) + return + } + + prm.logger.Info("transaction designating Notary role to the committee successfully recreated") + } else { + prm.logger.Info("previously made transaction designating Notary role to the committee is still relevant, continue with it") + } + + domain := designateNotarySignatureDomainForMember(prm.localAccCommitteeIndex) + + l = prm.logger.With(zap.String("domain", domain)) + + var recordExists bool + var needReset bool + + rec, err := lookupNNSDomainRecord(_invoker, prm.nnsOnChainAddress, domain) + if err != nil { + if errors.Is(err, errMissingDomain) { + l.Info("NNS domain is missing, registration is needed") + + if registerDomainTxValidUntilBlock > 0 { + l.Info("transaction registering NNS domain was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= registerDomainTxValidUntilBlock { + l.Info("previously sent transaction registering NNS domain may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", registerDomainTxValidUntilBlock)) + return + } + + l.Info("previously sent transaction registering NNS domain expired without side-effect") + } + + l.Info("sending new transaction registering domain in the NNS...") + + _, vub, err := _actor.SendCall(prm.nnsOnChainAddress, methodNNSRegister, + domain, _actor.Sender(), prm.systemEmail, nnsRefresh, nnsRetry, nnsExpire, nnsMinimum) + if err != nil { + registerDomainTxValidUntilBlock = 0 + if isErrNotEnoughGAS(err) { + prm.logger.Info("not enough GAS to register domain in the NNS, will try again later") + } else { + prm.logger.Error("failed to send transaction registering domain in the NNS, will try again later", zap.Error(err)) + } + return + } + + registerDomainTxValidUntilBlock = vub + + l.Info("transaction registering domain in the NNS has been successfully sent, will wait for the outcome") + + return + } else if !errors.Is(err, errMissingDomainRecord) { + l.Error("failed to lookup NNS domain record, will try again later", zap.Error(err)) + return + } + + l.Info("missing record of the NNS domain, needed to be set") + + if setDomainRecordTxValidUntilBlock > 0 { + l.Info("transaction setting NNS domain record was sent earlier, checking relevance...") + + if cur := monitor.currentHeight(); cur <= setDomainRecordTxValidUntilBlock { + l.Info("previously sent transaction setting NNS domain record may still be relevant, will wait for the outcome", + zap.Uint32("current height", cur), zap.Uint32("retry after height", setDomainRecordTxValidUntilBlock)) + return + } + + l.Info("previously sent transaction setting NNS domain record expired without side-effect") + } + + needReset = true + } else { + bRec, err := base64.StdEncoding.DecodeString(rec) + if err != nil { + l.Info("failed to decode NNS domain record with local account's signature of the transaction designating Notary role to the committee from base64, will wait for a background fix", + zap.String("domain", domain), zap.Error(err)) + return + } + + checksumMatches, bSignature := sharedTxData.shiftChecksum(bRec) + if !checksumMatches { + l.Info("checksum of shared data of the transaction designating Notary role to the committee submitted by committee member mismatches, need to be recalculated") + needReset = true + } else { + txCp := *tx // to safely call Hash method below + if !prm.localAcc.PublicKey().VerifyHashable(bSignature, uint32(_actor.GetNetwork()), &txCp) { + l.Info("invalid signature of the transaction designating Notary role to the committee submitted by local account, need to be recalculated") + needReset = true + } + } + + recordExists = true + } + + if needReset { + prm.logger.Info("calculating signature of the transaction designating Notary role to the committee using local account...") + + sig := prm.localAcc.SignHashable(_actor.GetNetwork(), tx) + sig = sharedTxData.unshiftChecksum(sig) + + rec = base64.StdEncoding.EncodeToString(sig) + + l.Info("sending new transaction setting domain record in the NNS...") + + var vub uint32 + if recordExists { + _, vub, err = _actor.SendCall(prm.nnsOnChainAddress, methodNNSSetRecord, + domain, int64(nns.TXT), 0, rec) + } else { + _, vub, err = _actor.SendCall(prm.nnsOnChainAddress, methodNNSAddRecord, + domain, int64(nns.TXT), rec) + } + if err != nil { + setDomainRecordTxValidUntilBlock = 0 + if isErrNotEnoughGAS(err) { + prm.logger.Info("not enough GAS to set NNS domain record, will try again later") + } else { + prm.logger.Error("failed to send transaction setting NNS domain record, will try again later", zap.Error(err)) + } + return + } + + setDomainRecordTxValidUntilBlock = vub + + l.Info("transaction setting NNS domain record has been successfully sent, will wait for the outcome") + + return + } + }, nil +} + +// sharedTransactionData groups transaction parameters that cannot be predicted +// in a decentralized way and need to be sent out. +type sharedTransactionData struct { + sender util.Uint160 + validUntilBlock uint32 + nonce uint32 +} + +// bytes serializes sharedTransactionData. +func (x sharedTransactionData) bytes() []byte { + b := make([]byte, sharedTransactionDataLen) + // fixed size is more convenient for potential format changes in the future + copy(b, x.sender.BytesBE()) + binary.BigEndian.PutUint32(b[util.Uint160Size:], x.validUntilBlock) + binary.BigEndian.PutUint32(b[util.Uint160Size+4:], x.nonce) + return b +} + +// encodeToString returns serialized sharedTransactionData in base64. +func (x sharedTransactionData) encodeToString() string { + return base64.StdEncoding.EncodeToString(x.bytes()) +} + +// decodeString decodes serialized sharedTransactionData from base64. +func (x *sharedTransactionData) decodeString(s string) (err error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return fmt.Errorf("decode shared transaction data from base64: %w", err) + } + + if len(b) != sharedTransactionDataLen { + return fmt.Errorf("invalid/unsupported length of shared transaction data: expected %d, got %d", + sharedTransactionDataLen, len(b)) + } + + x.sender, err = util.Uint160DecodeBytesBE(b[:util.Uint160Size]) + if err != nil { + return fmt.Errorf("decode sender account binary: %w", err) + } + + x.validUntilBlock = binary.BigEndian.Uint32(b[util.Uint160Size:]) + x.nonce = binary.BigEndian.Uint32(b[util.Uint160Size+4:]) + + return nil +} + +const ( + sharedTransactionDataLen = util.Uint160Size + 4 + 4 + sharedTransactionDataChecksumLen = 4 +) + +// unshiftChecksum prepends given payload with first 4 bytes of the +// sharedTransactionData SHA-256 checksum. Inverse operation to shiftChecksum. +func (x sharedTransactionData) unshiftChecksum(data []byte) []byte { + h := sha256.Sum256(x.bytes()) + return append(h[:sharedTransactionDataChecksumLen], data...) +} + +// shiftChecksum matches checksum of the sharedTransactionData and returns +// payload. Inverse operation to unshiftChecksum. +func (x sharedTransactionData) shiftChecksum(data []byte) (bool, []byte) { + if len(data) < sharedTransactionDataChecksumLen { + return false, data + } + + h := sha256.Sum256(x.bytes()) + if !bytes.HasPrefix(data, h[:sharedTransactionDataChecksumLen]) { + return false, nil + } + + return true, data[sharedTransactionDataChecksumLen:] +} + +// sharedTxDataMatches checks if given transaction is constructed using provided +// shared parameters. +func sharedTxDataMatches(tx *transaction.Transaction, sharedTxData sharedTransactionData) bool { + return sharedTxData.nonce == tx.Nonce && + sharedTxData.validUntilBlock == tx.ValidUntilBlock && + len(tx.Signers) > 0 && tx.Signers[0].Account.Equals(sharedTxData.sender) +} + +// makeUnsignedDesignateCommitteeNotaryTx constructs unsigned transaction that +// designates Notary role to the specified committee members using shared +// parameters. +func makeUnsignedDesignateCommitteeNotaryTx(roleContract *rolemgmt.Contract, committee keys.PublicKeys, sharedTxData sharedTransactionData) (*transaction.Transaction, error) { + committeeMultiSigAcc, err := notary.FakeMultisigAccount(smartcontract.GetMajorityHonestNodeCount(len(committee)), committee) + if err != nil { + return nil, fmt.Errorf("init committee multi-sig account: %w", err) + } + + tx, err := roleContract.DesignateAsRoleUnsigned(noderoles.P2PNotary, committee) + if err != nil { + return nil, err + } + + tx.ValidUntilBlock = sharedTxData.validUntilBlock + tx.Nonce = sharedTxData.nonce + tx.Signers = []transaction.Signer{ + { + Account: sharedTxData.sender, + Scopes: transaction.None, + }, + { + Account: committeeMultiSigAcc.ScriptHash(), + Scopes: transaction.CustomGroups | transaction.CalledByEntry, + }, + } + + // FIXME: w/o this transaction fails with 'invalid signature' error due to lack of GAS for some period + // (later succeeds when there is more GAS) + tx.NetworkFee *= 2 + + return tx, nil +} diff --git a/pkg/morph/deploy/util.go b/pkg/morph/deploy/util.go new file mode 100644 index 00000000000..0ed69e82ca3 --- /dev/null +++ b/pkg/morph/deploy/util.go @@ -0,0 +1,168 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/nspcc-dev/neo-go/pkg/core/block" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/neorpc" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/nef" + "github.com/nspcc-dev/neo-go/pkg/util" + "go.uber.org/atomic" + "go.uber.org/zap" +) + +// Error functions won't be needed when proper Neo status codes will arrive +// Track https://github.com/nspcc-dev/neofs-node/issues/2285 + +func isErrContractNotFound(err error) bool { + return strings.Contains(err.Error(), "Unknown contract") +} + +func isErrNotEnoughGAS(err error) bool { + return errors.Is(err, neorpc.ErrValidationFailed) && strings.Contains(err.Error(), "insufficient funds") +} + +func isErrTransactionExpired(err error) bool { + return errors.Is(err, neorpc.ErrValidationFailed) && strings.Contains(err.Error(), "transaction has expired") +} + +func setGroupInManifest(_manifest *manifest.Manifest, _nef nef.File, groupPrivKey *keys.PrivateKey, deployerAcc util.Uint160) { + contractAddress := state.CreateContractHash(deployerAcc, _nef.Checksum, _manifest.Name) + sig := groupPrivKey.Sign(contractAddress.BytesBE()) + groupPubKey := groupPrivKey.PublicKey() + + ind := -1 + + for i := range _manifest.Groups { + if _manifest.Groups[i].PublicKey.Equal(groupPubKey) { + ind = i + break + } + } + + if ind >= 0 { + _manifest.Groups[ind].Signature = sig + return + } + + _manifest.Groups = append(_manifest.Groups, manifest.Group{ + PublicKey: groupPubKey, + Signature: sig, + }) +} + +// blockchainMonitor is a thin utility around Blockchain providing state +// monitoring. +type blockchainMonitor struct { + logger *zap.Logger + + blockchain Blockchain + + blockInterval time.Duration + + subID string + height atomic.Uint32 +} + +// newBlockchainMonitor constructs and runs monitor for the given Blockchain. +// Resulting blockchainMonitor must be stopped when no longer needed. +func newBlockchainMonitor(l *zap.Logger, b Blockchain) (*blockchainMonitor, error) { + ver, err := b.GetVersion() + if err != nil { + return nil, fmt.Errorf("request Neo protocol configuration: %w", err) + } + + initialBlock, err := b.GetBlockCount() + if err != nil { + return nil, fmt.Errorf("get current blockchain height: %w", err) + } + + blockCh := make(chan *block.Block) + + newBlockSubID, err := b.ReceiveBlocks(nil, blockCh) + if err != nil { + return nil, fmt.Errorf("subscribe to new blocks of the chain: %w", err) + } + + res := &blockchainMonitor{ + logger: l, + blockchain: b, + blockInterval: time.Duration(ver.Protocol.MillisecondsPerBlock) * time.Millisecond, + subID: newBlockSubID, + } + + res.height.Store(initialBlock) + + go func() { + l.Info("listening to new blocks...") + for { + b, ok := <-blockCh + if !ok { + l.Info("listening to new blocks stopped") + return + } + + res.height.Store(b.Index) + + l.Info("new block arrived", zap.Uint32("height", b.Index)) + } + }() + + return res, nil +} + +// currentHeight returns current blockchain height. +func (x *blockchainMonitor) currentHeight() uint32 { + return x.height.Load() +} + +// waitForNextBlock blocks until blockchainMonitor encounters new block on the +// chain or provided context is done. +func (x *blockchainMonitor) waitForNextBlock(ctx context.Context) { + initialBlock := x.currentHeight() + + ticker := time.NewTicker(x.blockInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if x.height.Load() > initialBlock { + return + } + } + } +} + +// stop stops running blockchainMonitor. Stopped blockchainMonitor must not be +// used anymore. +func (x *blockchainMonitor) stop() { + err := x.blockchain.Unsubscribe(x.subID) + if err != nil { + x.logger.Warn("failed to cancel subscription to new blocks", zap.Error(err)) + } +} + +// readNNSOnChainState reads state of the NeoFS NNS contract in the given +// Blockchain. Returns both nil if contract is missing. +func readNNSOnChainState(b Blockchain) (*state.Contract, error) { + // NNS must always have ID=1 in the NeoFS Sidechain + const nnsContractID = 1 + res, err := b.GetContractStateByID(nnsContractID) + if err != nil { + if isErrContractNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("read contract state by ID=%d: %w", nnsContractID, err) + } + return res, nil +}