diff --git a/contracts/container/config.yml b/contracts/container/config.yml index 1d61ae7c..95c62cd8 100644 --- a/contracts/container/config.yml +++ b/contracts/container/config.yml @@ -1,5 +1,5 @@ name: "NeoFS Container" -safemethods: ["alias", "count", "containersOf", "get", "owner", "list", "eACL", "getContainerSize", "listContainerSizes", "iterateContainerSizes", "iterateAllContainerSizes", "version"] +safemethods: ["alias", "count", "containersOf", "get", "owner", "list", "nodes", "replicasNumbers", "eACL", "getContainerSize", "listContainerSizes", "iterateContainerSizes", "iterateAllContainerSizes", "version"] permissions: - methods: ["update", "addKey", "transferX", "register", "registerTLD", "addRecord", "deleteRecords", "subscribeForNewEpoch"] @@ -28,3 +28,7 @@ events: parameters: - name: epoch type: Integer + - name: NodesUpdate + parameters: + - name: ContainerID + type: hash256 diff --git a/contracts/container/containerconst/const.go b/contracts/container/containerconst/const.go index 147ddec2..895b950f 100644 --- a/contracts/container/containerconst/const.go +++ b/contracts/container/containerconst/const.go @@ -18,4 +18,14 @@ const ( // ErrorDeleted is returned on attempt to create previously deleted container. ErrorDeleted = "container was previously deleted" + + // ErrorTooBigNumberOfNodes is returned if it is assumed that REP or number of REPS + // in container's placement policy is bigger than 255. + ErrorTooBigNumberOfNodes = "number of container nodes exceeds limits" + + // ErrorInvalidContainerID is returned on an attempt to work with incorrect container ID. + ErrorInvalidContainerID = "invalid container id" + + // ErrorInvalidPublicKey is returned on an attempt to work with an incorrect public key. + ErrorInvalidPublicKey = "invalid public key" ) diff --git a/contracts/container/contract.go b/contracts/container/contract.go index 72d75884..64b58435 100644 --- a/contracts/container/contract.go +++ b/contracts/container/contract.go @@ -66,6 +66,9 @@ const ( containerKeyPrefix = 'x' ownerKeyPrefix = 'o' deletedKeyPrefix = 'd' + nodesPrefix = 'n' + replicasNumberPrefix = 'r' + nextEpochNodesPrefix = 'u' estimatePostfixSize = 10 // default SOA record field values. @@ -491,6 +494,179 @@ func List(owner []byte) [][]byte { return list } +// maxNumOfREPs is a max supported number of REP value in container's policy +// and also a max number of REPs. +const maxNumOfREPs = 255 + +// AddNextEpochNodes accumulates passed nodes as container members for the next +// epoch to be committed using [CommitContainerListUpdate]. Nodes must be +// grouped by selector index from placement policy (SELECT clauses). Results of +// the call operation can be received via [Nodes]. This method must be called +// only when a container list is changed, otherwise nothing should be done. +// Call must be signed by the Alphabet nodes. +func AddNextEpochNodes(cID interop.Hash256, placementVector uint8, publicKeys []interop.PublicKey) { + if len(cID) != interop.Hash256Len { + panic(cst.ErrorInvalidContainerID + ": length: " + std.Itoa10(len(cID))) + } + // nolint:staticcheck + if placementVector >= maxNumOfREPs { + panic(cst.ErrorTooBigNumberOfNodes + ": " + std.Itoa10(int(placementVector))) + } + ctx := storage.GetContext() + validatePlacementIndex(ctx, cID, placementVector) + + multiaddr := common.AlphabetAddress() + common.CheckAlphabetWitness(multiaddr) + + commonPrefix := append([]byte{nextEpochNodesPrefix}, cID...) + commonPrefix = append(commonPrefix, placementVector) + + counter := 0 + c := storage.Find(ctx, commonPrefix, storage.RemovePrefix|storage.KeysOnly|storage.Backwards) + if iterator.Next(c) { + counter = counterFromBytes(iterator.Value(c).([]byte)) + } + + for _, publicKey := range publicKeys { + if len(publicKey) != interop.PublicKeyCompressedLen { + panic(cst.ErrorInvalidPublicKey + ": length: " + std.Itoa10(len(publicKey))) + } + + counter++ + + storageKey := append(commonPrefix, counterToBytes(counter)...) + storage.Put(ctx, storageKey, publicKey) + } +} + +func counterToBytes(counter int) []byte { + var anyCounter any = counter + res := anyCounter.([]byte) + + switch len(res) { + case 0: + return []byte{0, 0} + case 1: + return []byte{0, res[0]} + default: + // only 2 bytes are expected, it should be ensured on the upper levels + } + + // BE for correct sorting + first := res[0] + res[0] = res[1] + res[1] = first + + return res +} + +func counterFromBytes(counter []byte) int { + first := counter[0] + counter[0] = counter[1] + counter[1] = first + + var anyCounter any = counter + + return anyCounter.(int) +} + +func validatePlacementIndex(ctx storage.Context, cID interop.Hash256, inx uint8) { + if inx == 0 { + return + } + + commonPrefix := append([]byte{nextEpochNodesPrefix}, cID...) + iter := storage.Find(ctx, append(commonPrefix, inx-1), storage.None) + if !iterator.Next(iter) { + panic("invalid placement vector: " + std.Itoa10(int(inx-1)) + " index not found but " + std.Itoa10(int(inx)) + " requested") + } +} + +// CommitContainerListUpdate commits container list changes made by +// [AddNextEpochNodes] calls in advance. Replicas must correspond to +// ordered placement policy (REP clauses). If no [AddNextEpochNodes] +// have been made, it clears container list. Makes "ContainerUpdate" +// notification with container ID after successful list change. +// Call must be signed by the Alphabet nodes. +func CommitContainerListUpdate(cID interop.Hash256, replicas []uint8) { + if len(cID) != interop.Hash256Len { + panic(cst.ErrorInvalidContainerID + ": length: " + std.Itoa10(len(cID))) + } + + ctx := storage.GetContext() + multiaddr := common.AlphabetAddress() + common.CheckAlphabetWitness(multiaddr) + + oldNodesPrefix := append([]byte{nodesPrefix}, cID...) + newNodesPrefix := append([]byte{nextEpochNodesPrefix}, cID...) + replicasPrefix := append([]byte{replicasNumberPrefix}, cID...) + + oldNodes := storage.Find(ctx, oldNodesPrefix, storage.KeysOnly) + for iterator.Next(oldNodes) { + oldNode := iterator.Value(oldNodes).(string) + storage.Delete(ctx, oldNode) + } + + newNodes := storage.Find(ctx, newNodesPrefix, storage.None) + for iterator.Next(newNodes) { + newNode := iterator.Value(newNodes).(struct { + key []byte + val []byte + }) + + storage.Delete(ctx, newNode.key) + + newKey := append([]byte{nodesPrefix}, newNode.key[1:]...) + storage.Put(ctx, newKey, newNode.val) + } + + rr := storage.Find(ctx, replicasPrefix, storage.KeysOnly) + for iterator.Next(rr) { + oldReplicasNumber := iterator.Value(rr).([]byte) + storage.Delete(ctx, oldReplicasNumber) + } + + // nolint:gosimple // https://github.com/nspcc-dev/neo-go/issues/3608 + if replicas != nil { + for i, replica := range replicas { + if replica > maxNumOfREPs { + panic(cst.ErrorTooBigNumberOfNodes + ": " + std.Itoa10(int(replica))) + } + + storage.Put(ctx, append(replicasPrefix, uint8(i)), replica) + } + } + + runtime.Notify("NodesUpdate", cID) +} + +// ReplicasNumbers returns iterator over saved by [CommitContainerListUpdate] +// container's replicas from placement policy. +func ReplicasNumbers(cID interop.Hash256) iterator.Iterator { + if len(cID) != interop.Hash256Len { + panic(cst.ErrorInvalidContainerID + ": length: " + std.Itoa10(len(cID))) + } + + ctx := storage.GetReadOnlyContext() + + return storage.Find(ctx, append([]byte{replicasNumberPrefix}, cID...), storage.ValuesOnly) +} + +// Nodes returns iterator over members of the container. The list is handled +// by the Alphabet nodes and must be updated via [AddNextEpochNodes] and +// [CommitContainerListUpdate] calls. +func Nodes(cID interop.Hash256, placementVector uint8) iterator.Iterator { + if len(cID) != interop.Hash256Len { + panic(cst.ErrorInvalidContainerID + ": length: " + std.Itoa10(len(cID))) + } + + ctx := storage.GetReadOnlyContext() + key := append([]byte{nodesPrefix}, cID...) + key = append(key, placementVector) + + return storage.Find(ctx, key, storage.ValuesOnly) +} + // SetEACL method sets a new extended ACL table related to the contract // if it was invoked by Alphabet nodes of the Inner Ring. Otherwise, it produces // setEACL notification. diff --git a/contracts/container/contract.nef b/contracts/container/contract.nef index 1423863e..aee36071 100755 Binary files a/contracts/container/contract.nef and b/contracts/container/contract.nef differ diff --git a/contracts/container/doc.go b/contracts/container/doc.go index 6030702e..aec1b921 100644 --- a/contracts/container/doc.go +++ b/contracts/container/doc.go @@ -36,6 +36,13 @@ and validate container ownership, signature and token if present. - name: token type: ByteArray +nodesUpdate notification. This notification is produced when a container roster +is changed. Triggered only by the Alphabet at the beginning of epoch. + + name: NodesUpdate + - name: ContainerID + type: hash256 + setEACL notification. This notification is produced when a container owner wants to update an extended ACL of a container. Alphabet nodes of the Inner Ring catch the notification and validate container ownership, signature and token if @@ -103,6 +110,14 @@ Key-value storage format: - 'est' + [20]byte -> [] list of NeoFS epochs when particular storage node sent estimations. Suffix is RIPEMD-160 hash of the storage node's public key (interop.PublicKey). + - 'n' -> interop.PublicKey + one of the container nodes' public key, counter is 2-bytes long BE + - 'u' -> interop.PublicKey + one of the container nodes' public key _for the next epoch_, they will become + the current ones (with the 'n' prefix) once the Alphabet handles epoch update. + Counter is 2-bytes long BE + - 'r' -> int (not bigger than uint8) + REP clause from placement policy for # Setting To handle some events, the contract refers to other contracts. diff --git a/contracts/container/manifest.json b/contracts/container/manifest.json index 40cf49f8..a87775d5 100755 --- a/contracts/container/manifest.json +++ b/contracts/container/manifest.json @@ -1 +1 @@ -{"name":"NeoFS Container","abi":{"methods":[{"name":"_initialize","offset":0,"parameters":[],"returntype":"Void","safe":false},{"name":"_deploy","offset":83,"parameters":[{"name":"data","type":"Any"},{"name":"isUpdate","type":"Boolean"}],"returntype":"Void","safe":false},{"name":"alias","offset":3697,"parameters":[{"name":"cid","type":"ByteArray"}],"returntype":"String","safe":true},{"name":"containersOf","offset":3837,"parameters":[{"name":"owner","type":"ByteArray"}],"returntype":"InteropInterface","safe":true},{"name":"count","offset":3792,"parameters":[],"returntype":"Integer","safe":true},{"name":"delete","offset":3187,"parameters":[{"name":"containerID","type":"ByteArray"},{"name":"signature","type":"Signature"},{"name":"token","type":"ByteArray"}],"returntype":"Void","safe":false},{"name":"eACL","offset":4249,"parameters":[{"name":"containerID","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"get","offset":3584,"parameters":[{"name":"containerID","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"getContainerSize","offset":4509,"parameters":[{"name":"id","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"iterateAllContainerSizes","offset":4882,"parameters":[{"name":"epoch","type":"Integer"}],"returntype":"InteropInterface","safe":true},{"name":"iterateContainerSizes","offset":4784,"parameters":[{"name":"epoch","type":"Integer"},{"name":"cid","type":"Hash256"}],"returntype":"InteropInterface","safe":true},{"name":"list","offset":3891,"parameters":[{"name":"owner","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"listContainerSizes","offset":4623,"parameters":[{"name":"epoch","type":"Integer"}],"returntype":"Array","safe":true},{"name":"newEpoch","offset":4934,"parameters":[{"name":"epochNum","type":"Integer"}],"returntype":"Void","safe":false},{"name":"onNEP11Payment","offset":1646,"parameters":[{"name":"a","type":"Hash160"},{"name":"b","type":"Integer"},{"name":"c","type":"ByteArray"},{"name":"d","type":"Any"}],"returntype":"Void","safe":false},{"name":"owner","offset":3646,"parameters":[{"name":"containerID","type":"ByteArray"}],"returntype":"ByteArray","safe":true},{"name":"put","offset":2037,"parameters":[{"name":"container","type":"ByteArray"},{"name":"signature","type":"Signature"},{"name":"publicKey","type":"PublicKey"},{"name":"token","type":"ByteArray"}],"returntype":"Void","safe":false},{"name":"putContainerSize","offset":4307,"parameters":[{"name":"epoch","type":"Integer"},{"name":"cid","type":"ByteArray"},{"name":"usedSize","type":"Integer"},{"name":"pubKey","type":"PublicKey"}],"returntype":"Void","safe":false},{"name":"putNamed","offset":2053,"parameters":[{"name":"container","type":"ByteArray"},{"name":"signature","type":"Signature"},{"name":"publicKey","type":"PublicKey"},{"name":"token","type":"ByteArray"},{"name":"name","type":"String"},{"name":"zone","type":"String"}],"returntype":"Void","safe":false},{"name":"setEACL","offset":3987,"parameters":[{"name":"eACL","type":"ByteArray"},{"name":"signature","type":"Signature"},{"name":"publicKey","type":"PublicKey"},{"name":"token","type":"ByteArray"}],"returntype":"Void","safe":false},{"name":"startContainerEstimation","offset":4964,"parameters":[{"name":"epoch","type":"Integer"}],"returntype":"Void","safe":false},{"name":"stopContainerEstimation","offset":5045,"parameters":[{"name":"epoch","type":"Integer"}],"returntype":"Void","safe":false},{"name":"update","offset":1904,"parameters":[{"name":"script","type":"ByteArray"},{"name":"manifest","type":"ByteArray"},{"name":"data","type":"Any"}],"returntype":"Void","safe":false},{"name":"version","offset":5125,"parameters":[],"returntype":"Integer","safe":true}],"events":[{"name":"PutSuccess","parameters":[{"name":"containerID","type":"Hash256"},{"name":"publicKey","type":"PublicKey"}]},{"name":"DeleteSuccess","parameters":[{"name":"containerID","type":"ByteArray"}]},{"name":"SetEACLSuccess","parameters":[{"name":"containerID","type":"ByteArray"},{"name":"publicKey","type":"PublicKey"}]},{"name":"StartEstimation","parameters":[{"name":"epoch","type":"Integer"}]},{"name":"StopEstimation","parameters":[{"name":"epoch","type":"Integer"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":["update","addKey","transferX","register","registerTLD","addRecord","deleteRecords","subscribeForNewEpoch"]}],"supportedstandards":[],"trusts":[],"extra":null} \ No newline at end of file +{"name":"NeoFS Container","abi":{"methods":[{"name":"_initialize","offset":0,"parameters":[],"returntype":"Void","safe":false},{"name":"_deploy","offset":83,"parameters":[{"name":"data","type":"Any"},{"name":"isUpdate","type":"Boolean"}],"returntype":"Void","safe":false},{"name":"addNextEpochNodes","offset":3987,"parameters":[{"name":"cID","type":"Hash256"},{"name":"placementVector","type":"Integer"},{"name":"publicKeys","type":"Array"}],"returntype":"Void","safe":false},{"name":"alias","offset":3697,"parameters":[{"name":"cid","type":"ByteArray"}],"returntype":"String","safe":true},{"name":"commitContainerListUpdate","offset":4592,"parameters":[{"name":"cID","type":"Hash256"},{"name":"replicas","type":"ByteArray"}],"returntype":"Void","safe":false},{"name":"containersOf","offset":3837,"parameters":[{"name":"owner","type":"ByteArray"}],"returntype":"InteropInterface","safe":true},{"name":"count","offset":3792,"parameters":[],"returntype":"Integer","safe":true},{"name":"delete","offset":3187,"parameters":[{"name":"containerID","type":"ByteArray"},{"name":"signature","type":"Signature"},{"name":"token","type":"ByteArray"}],"returntype":"Void","safe":false},{"name":"eACL","offset":5547,"parameters":[{"name":"containerID","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"get","offset":3584,"parameters":[{"name":"containerID","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"getContainerSize","offset":5807,"parameters":[{"name":"id","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"iterateAllContainerSizes","offset":6180,"parameters":[{"name":"epoch","type":"Integer"}],"returntype":"InteropInterface","safe":true},{"name":"iterateContainerSizes","offset":6082,"parameters":[{"name":"epoch","type":"Integer"},{"name":"cid","type":"Hash256"}],"returntype":"InteropInterface","safe":true},{"name":"list","offset":3891,"parameters":[{"name":"owner","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"listContainerSizes","offset":5921,"parameters":[{"name":"epoch","type":"Integer"}],"returntype":"Array","safe":true},{"name":"newEpoch","offset":6232,"parameters":[{"name":"epochNum","type":"Integer"}],"returntype":"Void","safe":false},{"name":"nodes","offset":5161,"parameters":[{"name":"cID","type":"Hash256"},{"name":"placementVector","type":"Integer"}],"returntype":"InteropInterface","safe":true},{"name":"onNEP11Payment","offset":1646,"parameters":[{"name":"a","type":"Hash160"},{"name":"b","type":"Integer"},{"name":"c","type":"ByteArray"},{"name":"d","type":"Any"}],"returntype":"Void","safe":false},{"name":"owner","offset":3646,"parameters":[{"name":"containerID","type":"ByteArray"}],"returntype":"ByteArray","safe":true},{"name":"put","offset":2037,"parameters":[{"name":"container","type":"ByteArray"},{"name":"signature","type":"Signature"},{"name":"publicKey","type":"PublicKey"},{"name":"token","type":"ByteArray"}],"returntype":"Void","safe":false},{"name":"putContainerSize","offset":5605,"parameters":[{"name":"epoch","type":"Integer"},{"name":"cid","type":"ByteArray"},{"name":"usedSize","type":"Integer"},{"name":"pubKey","type":"PublicKey"}],"returntype":"Void","safe":false},{"name":"putNamed","offset":2053,"parameters":[{"name":"container","type":"ByteArray"},{"name":"signature","type":"Signature"},{"name":"publicKey","type":"PublicKey"},{"name":"token","type":"ByteArray"},{"name":"name","type":"String"},{"name":"zone","type":"String"}],"returntype":"Void","safe":false},{"name":"replicasNumbers","offset":5063,"parameters":[{"name":"cID","type":"Hash256"}],"returntype":"InteropInterface","safe":true},{"name":"setEACL","offset":5285,"parameters":[{"name":"eACL","type":"ByteArray"},{"name":"signature","type":"Signature"},{"name":"publicKey","type":"PublicKey"},{"name":"token","type":"ByteArray"}],"returntype":"Void","safe":false},{"name":"startContainerEstimation","offset":6262,"parameters":[{"name":"epoch","type":"Integer"}],"returntype":"Void","safe":false},{"name":"stopContainerEstimation","offset":6343,"parameters":[{"name":"epoch","type":"Integer"}],"returntype":"Void","safe":false},{"name":"update","offset":1904,"parameters":[{"name":"script","type":"ByteArray"},{"name":"manifest","type":"ByteArray"},{"name":"data","type":"Any"}],"returntype":"Void","safe":false},{"name":"version","offset":6423,"parameters":[],"returntype":"Integer","safe":true}],"events":[{"name":"PutSuccess","parameters":[{"name":"containerID","type":"Hash256"},{"name":"publicKey","type":"PublicKey"}]},{"name":"DeleteSuccess","parameters":[{"name":"containerID","type":"ByteArray"}]},{"name":"SetEACLSuccess","parameters":[{"name":"containerID","type":"ByteArray"},{"name":"publicKey","type":"PublicKey"}]},{"name":"StartEstimation","parameters":[{"name":"epoch","type":"Integer"}]},{"name":"StopEstimation","parameters":[{"name":"epoch","type":"Integer"}]},{"name":"NodesUpdate","parameters":[{"name":"ContainerID","type":"Hash256"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":["update","addKey","transferX","register","registerTLD","addRecord","deleteRecords","subscribeForNewEpoch"]}],"supportedstandards":[],"trusts":[],"extra":null} \ No newline at end of file diff --git a/rpc/container/rpcbinding.go b/rpc/container/rpcbinding.go index 98bfcb5e..4363ed91 100644 --- a/rpc/container/rpcbinding.go +++ b/rpc/container/rpcbinding.go @@ -79,6 +79,11 @@ type StopEstimationEvent struct { Epoch *big.Int } +// NodesUpdateEvent represents "NodesUpdate" event emitted by the contract. +type NodesUpdateEvent struct { + ContainerID util.Uint256 +} + // Invoker is used by ContractReader to call various safe methods. type Invoker interface { Call(contract util.Uint160, operation string, params ...any) (*result.Invoke, error) @@ -199,16 +204,88 @@ func (c *ContractReader) ListContainerSizes(epoch *big.Int) ([][]byte, error) { return unwrap.ArrayOfBytes(c.invoker.Call(c.hash, "listContainerSizes", epoch)) } +// Nodes invokes `nodes` method of contract. +func (c *ContractReader) Nodes(cID util.Uint256, placementVector *big.Int) (uuid.UUID, result.Iterator, error) { + return unwrap.SessionIterator(c.invoker.Call(c.hash, "nodes", cID, placementVector)) +} + +// NodesExpanded is similar to Nodes (uses the same contract +// method), but can be useful if the server used doesn't support sessions and +// doesn't expand iterators. It creates a script that will get the specified +// number of result items from the iterator right in the VM and return them to +// you. It's only limited by VM stack and GAS available for RPC invocations. +func (c *ContractReader) NodesExpanded(cID util.Uint256, placementVector *big.Int, _numOfIteratorItems int) ([]stackitem.Item, error) { + return unwrap.Array(c.invoker.CallAndExpandIterator(c.hash, "nodes", _numOfIteratorItems, cID, placementVector)) +} + // Owner invokes `owner` method of contract. func (c *ContractReader) Owner(containerID []byte) ([]byte, error) { return unwrap.Bytes(c.invoker.Call(c.hash, "owner", containerID)) } +// ReplicasNumbers invokes `replicasNumbers` method of contract. +func (c *ContractReader) ReplicasNumbers(cID util.Uint256) (uuid.UUID, result.Iterator, error) { + return unwrap.SessionIterator(c.invoker.Call(c.hash, "replicasNumbers", cID)) +} + +// ReplicasNumbersExpanded is similar to ReplicasNumbers (uses the same contract +// method), but can be useful if the server used doesn't support sessions and +// doesn't expand iterators. It creates a script that will get the specified +// number of result items from the iterator right in the VM and return them to +// you. It's only limited by VM stack and GAS available for RPC invocations. +func (c *ContractReader) ReplicasNumbersExpanded(cID util.Uint256, _numOfIteratorItems int) ([]stackitem.Item, error) { + return unwrap.Array(c.invoker.CallAndExpandIterator(c.hash, "replicasNumbers", _numOfIteratorItems, cID)) +} + // Version invokes `version` method of contract. func (c *ContractReader) Version() (*big.Int, error) { return unwrap.BigInt(c.invoker.Call(c.hash, "version")) } +// AddNextEpochNodes creates a transaction invoking `addNextEpochNodes` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) AddNextEpochNodes(cID util.Uint256, placementVector *big.Int, publicKeys keys.PublicKeys) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "addNextEpochNodes", cID, placementVector, publicKeys) +} + +// AddNextEpochNodesTransaction creates a transaction invoking `addNextEpochNodes` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) AddNextEpochNodesTransaction(cID util.Uint256, placementVector *big.Int, publicKeys keys.PublicKeys) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "addNextEpochNodes", cID, placementVector, publicKeys) +} + +// AddNextEpochNodesUnsigned creates a transaction invoking `addNextEpochNodes` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) AddNextEpochNodesUnsigned(cID util.Uint256, placementVector *big.Int, publicKeys keys.PublicKeys) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "addNextEpochNodes", nil, cID, placementVector, publicKeys) +} + +// CommitContainerListUpdate creates a transaction invoking `commitContainerListUpdate` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) CommitContainerListUpdate(cID util.Uint256, replicas []byte) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "commitContainerListUpdate", cID, replicas) +} + +// CommitContainerListUpdateTransaction creates a transaction invoking `commitContainerListUpdate` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) CommitContainerListUpdateTransaction(cID util.Uint256, replicas []byte) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "commitContainerListUpdate", cID, replicas) +} + +// CommitContainerListUpdateUnsigned creates a transaction invoking `commitContainerListUpdate` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) CommitContainerListUpdateUnsigned(cID util.Uint256, replicas []byte) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "commitContainerListUpdate", nil, cID, replicas) +} + // Delete creates a transaction invoking `delete` method of the contract. // This transaction is signed and immediately sent to the network. // The values returned are its hash, ValidUntilBlock value and error if any. @@ -1004,3 +1081,65 @@ func (e *StopEstimationEvent) FromStackItem(item *stackitem.Array) error { return nil } + +// NodesUpdateEventsFromApplicationLog retrieves a set of all emitted events +// with "NodesUpdate" name from the provided [result.ApplicationLog]. +func NodesUpdateEventsFromApplicationLog(log *result.ApplicationLog) ([]*NodesUpdateEvent, error) { + if log == nil { + return nil, errors.New("nil application log") + } + + var res []*NodesUpdateEvent + for i, ex := range log.Executions { + for j, e := range ex.Events { + if e.Name != "NodesUpdate" { + continue + } + event := new(NodesUpdateEvent) + err := event.FromStackItem(e.Item) + if err != nil { + return nil, fmt.Errorf("failed to deserialize NodesUpdateEvent from stackitem (execution #%d, event #%d): %w", i, j, err) + } + res = append(res, event) + } + } + + return res, nil +} + +// FromStackItem converts provided [stackitem.Array] to NodesUpdateEvent or +// returns an error if it's not possible to do to so. +func (e *NodesUpdateEvent) FromStackItem(item *stackitem.Array) error { + if item == nil { + return errors.New("nil item") + } + arr, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not an array") + } + if len(arr) != 1 { + return errors.New("wrong number of structure elements") + } + + var ( + index = -1 + err error + ) + index++ + e.ContainerID, err = func(item stackitem.Item) (util.Uint256, error) { + b, err := item.TryBytes() + if err != nil { + return util.Uint256{}, err + } + u, err := util.Uint256DecodeBytesBE(b) + if err != nil { + return util.Uint256{}, err + } + return u, nil + }(arr[index]) + if err != nil { + return fmt.Errorf("field ContainerID: %w", err) + } + + return nil +} diff --git a/tests/container_test.go b/tests/container_test.go index bd615db5..7b9c0d74 100644 --- a/tests/container_test.go +++ b/tests/container_test.go @@ -2,7 +2,9 @@ package tests import ( "bytes" + "crypto/rand" "crypto/sha256" + "fmt" "math/big" "path" "testing" @@ -12,6 +14,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/encoding/address" "github.com/nspcc-dev/neo-go/pkg/neotest" "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm" "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" "github.com/nspcc-dev/neofs-contract/common" "github.com/nspcc-dev/neofs-contract/contracts/container/containerconst" @@ -560,3 +563,152 @@ func requireEstimationsMatch(t *testing.T, expected []estimation, actual []estim require.True(t, found, "expected estimation from %x to be present", e.from) } } + +func TestContainerList(t *testing.T) { + c, _, _ := newContainerInvoker(t, false) + + t.Run("happy path", func(t *testing.T) { + cID := make([]byte, sha256.Size) + _, err := rand.Read(cID) + require.NoError(t, err) + + const nodesNumber = 1024 + const numberOfVectors = 4 + var containerList []any + for range nodesNumber { + s := c.NewAccount(t).(neotest.SingleSigner) + containerList = append(containerList, s.Account().PublicKey().Bytes()) + } + + var replicas []uint8 + var vectors [][]any + for i := range numberOfVectors { + vector := containerList[nodesNumber/numberOfVectors*i : nodesNumber/numberOfVectors*(i+1)] + vectors = append(vectors, vector) + replicas = append(replicas, uint8(i+1)) + + c.Invoke(t, stackitem.Null{}, "addNextEpochNodes", cID, i, vector) + } + c.Invoke(t, stackitem.Null{}, "commitContainerListUpdate", cID, replicas) + + stack, err := c.TestInvoke(t, "replicasNumbers", cID) + require.NoError(t, err) + + replicasFromContract := stackIteratorToUint8Array(t, stack) + require.Equal(t, replicas, replicasFromContract) + + for i := range numberOfVectors { + stack, err := c.TestInvoke(t, "nodes", cID, uint8(i)) + require.NoError(t, err) + + fromContract := stackIteratorToBytesArray(t, stack) + require.ElementsMatch(t, vectors[i], fromContract) + } + + // change container + for i := nodesNumber / 2; i < nodesNumber; i++ { + s := c.NewAccount(t).(neotest.SingleSigner) + containerList[i] = s.Account().PublicKey().Bytes() + } + + c.Invoke(t, stackitem.Null{}, "addNextEpochNodes", cID, 0, containerList) + c.Invoke(t, stackitem.Null{}, "commitContainerListUpdate", cID, []uint8{1}) + stack, err = c.TestInvoke(t, "nodes", cID, 0) + require.NoError(t, err) + + fromContract := stackIteratorToBytesArray(t, stack) + require.ElementsMatch(t, containerList, fromContract) + + // clean container up + c.Invoke(t, stackitem.Null{}, "commitContainerListUpdate", cID, nil) + + for i := range numberOfVectors { + stack, err = c.TestInvoke(t, "nodes", cID, i) + require.NoError(t, err) + + fromContract = stackIteratorToBytesArray(t, stack) + require.Empty(t, fromContract) + } + + // cleaning cleaned container + c.Invoke(t, stackitem.Null{}, "commitContainerListUpdate", cID, nil) + + for i := range numberOfVectors { + stack, err = c.TestInvoke(t, "nodes", cID, i) + require.NoError(t, err) + + fromContract = stackIteratorToBytesArray(t, stack) + require.Empty(t, fromContract) + } + + stack, err = c.TestInvoke(t, "replicasNumbers", cID) + require.NoError(t, err) + + replicasFromContract = stackIteratorToUint8Array(t, stack) + require.Empty(t, replicasFromContract) + }) + + t.Run("unknown placement vector", func(t *testing.T) { + const requestedIndex = 1 + c.InvokeFail(t, fmt.Sprintf("invalid placement vector: %d index not found but %d requested", requestedIndex-1, requestedIndex), + "addNextEpochNodes", make([]byte, 32), requestedIndex, nil) + }) + + t.Run("invalid container ID", func(t *testing.T) { + badCID := []byte{1, 2, 3, 4, 5} + + c.InvokeFail(t, fmt.Sprintf("%s: length: %d", containerconst.ErrorInvalidContainerID, len(badCID)), "addNextEpochNodes", badCID, 0, nil) + c.InvokeFail(t, fmt.Sprintf("%s: length: %d", containerconst.ErrorInvalidContainerID, len(badCID)), "commitContainerListUpdate", badCID, 0) + c.InvokeFail(t, fmt.Sprintf("%s: length: %d", containerconst.ErrorInvalidContainerID, len(badCID)), "nodes", badCID, 0) + }) + + t.Run("invalid public key", func(t *testing.T) { + cID := make([]byte, sha256.Size) + _, err := rand.Read(cID) + require.NoError(t, err) + + badPublicKey := []byte{1, 2, 3, 4, 5} + + c.InvokeFail(t, fmt.Sprintf("%s: length: %d", containerconst.ErrorInvalidPublicKey, len(badPublicKey)), "addNextEpochNodes", cID, 0, []any{badPublicKey}) + }) + + t.Run("not alphabet", func(t *testing.T) { + cID := make([]byte, sha256.Size) + _, err := rand.Read(cID) + require.NoError(t, err) + + notAlphabet := c.Executor.NewInvoker(c.Hash, c.NewAccount(t)) + notAlphabet.InvokeFail(t, common.ErrAlphabetWitnessFailed, "addNextEpochNodes", cID, 0, nil) + notAlphabet.InvokeFail(t, common.ErrAlphabetWitnessFailed, "commitContainerListUpdate", cID, 0) + }) +} + +func stackIteratorToBytesArray(t *testing.T, stack *vm.Stack) [][]byte { + i, ok := stack.Pop().Value().(*storage.Iterator) + require.True(t, ok) + + res := make([][]byte, 0) + for i.Next() { + b, err := i.Value().TryBytes() + require.NoError(t, err) + + res = append(res, b) + } + + return res +} + +func stackIteratorToUint8Array(t *testing.T, stack *vm.Stack) []uint8 { + i, ok := stack.Pop().Value().(*storage.Iterator) + require.True(t, ok) + + res := make([]uint8, 0) + for i.Next() { + b, err := i.Value().TryInteger() + require.NoError(t, err) + + res = append(res, uint8(b.Uint64())) + } + + return res +}