diff --git a/docs/globally-unique-domain-zone.md b/docs/globally-unique-domain-zone.md
new file mode 100644
index 0000000..25872cc
--- /dev/null
+++ b/docs/globally-unique-domain-zone.md
@@ -0,0 +1,160 @@
+# Globally unique domain zone
+
+**Make sure you understand the [basic concepts](../nns/README.md) of `NNS`.**
+
+`Globally Unique Domains Zone` (`GUDZ`) is an extension of `NNS` that ensures unique names across multiple domain zones. When this option is enabled, all newly created domains will automatically receive a corresponding alias in the designated global zone. Deleting a domain will also remove its alias from the global zone.
+
+It's important to note that this feature is not retroactive: domains created before this option is enabled will not receive a global alias. Likewise, if the option is later disabled, domains that already have a `GUDZ` alias will retain their records. To fully disable `GUDZ`, all domains must be recreated with the option turned off.
+
+To enable `GUDZ`, add a `cnametgt=$(global domain)` `TXT` record that specifies the global zone.
+
+**Example:**
+
+Domains:
+- `poland`
+- `sweden`
+- `animals.org`
+
+It is necessary to associate the domain zones `.poland` and `.sweden` into the global zone `.animals`.
+
+![](img/GUDZ.png)
+
+Create domains:
+
+```
+frostfs-adm morph nns register --name="poland" --email="email@email.email"
+frostfs-adm morph nns register --name="sweden" --email="email@email.email"
+frostfs-adm morph nns register --name="org" --email="email@email.email"
+frostfs-adm morph nns register --name="animals.org" --email="email@email.email"
+```
+
+Add the `cnametgt` records:
+
+```
+frostfs-adm morph nns add-record --name="poland" --data="cnametgt=animals.org" --type="txt"
+frostfs-adm morph nns add-record --name="sweden" --data="cnametgt=animals.org" --type="txt"
+```
+
+Create a domain with mapping to the global zone:
+
+```
+frostfs-adm morph nns register --name="bober.poland" --email="email@email.email"
+```
+
+Add any `TXT` record
+
+```
+frostfs-adm morph nns add-record --name="bober.poland" --data="CID" --type="txt"
+```
+
+Verify that the created domain has alias in the global zone
+
+```
+frostfs-adm morph nns tokens -v
+
+balance.frostfs
+animals.org
+group.frostfs
+container
+org
+container.frostfs
+proxy.frostfs
+policy.frostfs
+alphabet0.frostfs
+sweden
+frostfsid.frostfs
+bober.animals.org (CNAME: bober.poland)
+netmap.frostfs
+frostfs
+poland
+bober.poland
+```
+
+Create of a conflicting domain
+```
+frostfs-adm morph nns register --name="bober.sweden" --email="email@email.email"
+
+unable to register domain: script failed (FAULT state) due to an error: at instruction 1263 (THROW): unhandled exception: "global domain is already taken: bober.animals.org. Domain: bober.poland
+```
+
+**Disable GUDZ**
+Delete `cnametgt` records
+
+```
+frostfs-adm morph nns delete-records --type=txt --name=poland
+```
+Create `hamster.poland` and `hamster.sweden`
+```
+frostfs-adm morph nns register --name="hamster.poland" --email="email@email.email"
+frostfs-adm morph nns register --name="hamster.sweden" --email="email@email.email"
+```
+`hamster.poland` and `hamster.sweden` does not have alias
+```
+frostfs-adm morph nns tokens -v
+balance.frostfs
+animals.org
+group.frostfs
+container
+org
+container.frostfs
+proxy.frostfs
+policy.frostfs
+alphabet0.frostfs
+sweden
+frostfsid.frostfs
+bober.animals.org (CNAME: bober.poland)
+netmap.frostfs
+frostfs
+poland
+bober.poland
+hamster.poland
+```
+Delete global alias of `bober.poland`
+
+```
+ frostfs-adm morph nns delete-records --name="bober.poland" --type="txt"
+```
+
+
+```
+frostfs-adm morph nns tokens -v
+balance.frostfs
+animals.org
+group.frostfs
+container
+org
+container.frostfs
+proxy.frostfs
+policy.frostfs
+alphabet0.frostfs
+sweden
+frostfsid.frostfs
+netmap.frostfs
+frostfs
+poland
+bober.poland
+hamster.poland
+```
+Delete `bober.poland`
+```
+ frostfs-adm morph nns delete --name="bober.poland"
+```
+
+```
+frostfs-adm morph nns tokens -v
+balance.frostfs
+animals.org
+group.frostfs
+container
+org
+container.frostfs
+proxy.frostfs
+policy.frostfs
+alphabet0.frostfs
+sweden
+frostfsid.frostfs
+netmap.frostfs
+frostfs
+poland
+hamster.poland
+```
\ No newline at end of file
diff --git a/docs/img/GUDZ.drawio b/docs/img/GUDZ.drawio
new file mode 100644
index 0000000..13ddf92
--- /dev/null
+++ b/docs/img/GUDZ.drawio
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/img/GUDZ.png b/docs/img/GUDZ.png
new file mode 100644
index 0000000..6d1057d
Binary files /dev/null and b/docs/img/GUDZ.png differ
diff --git a/nns/README.md b/nns/README.md
new file mode 100644
index 0000000..18644d1
--- /dev/null
+++ b/nns/README.md
@@ -0,0 +1,46 @@
+# NNS
+NNS - Neo Name Service is a service that allows manage a domain name as a digital asset (NFT). It has an interface similar to `DNS` but has significant differences in its internal structure.
+
+## Entities:
+
+- Domain
+- Record
+- Owner
+- Committee
+
+### Domain
+
+Domain is string that satisfies the following requirements:
+- Length from 2 to 255 characters.
+- Root domain must start with a letter.
+- All other fragments must start and end with a letter or digit.
+
+Domain has owner, a registration period, and may optionally have records.
+
+A fee established by the committee is charged upon domain registration. After registration, the owner can manage this asset (add/delete records, transfer ownership to another owner) until the end of the domain registration period.
+
+### Record
+
+A record is a pair of values ``.
+
+Supported record types:
+
+| Type | Description |
+|-------|-------------------------------------------|
+| A | Represents address record type |
+| AAA | Represents IPv6 address record type |
+| TXT | Represents text record type |
+| CNAME | Represents canonical name record type |
+| SOA | Represents start of authority record type |
+
+### Owner
+
+An owner is a wallet that has the right to manage this NFT (domain).
+
+### Committee
+
+The committee makes new tokens (domains), sets, and charges a fee for issuance.
+
+## Globally Unique Domain Zone
+
+For more information, see [here](../docs/globally-unique-domain-zone.md).
diff --git a/nns/config.yml b/nns/config.yml
index 9ae83cd..7ff600f 100644
--- a/nns/config.yml
+++ b/nns/config.yml
@@ -14,6 +14,12 @@ events:
type: String
- name: type
type: Integer
+ - name: DeleteRecord
+ parameters:
+ - name: name
+ type: String
+ - name: type
+ type: Integer
- name: DeleteRecords
parameters:
- name: name
diff --git a/nns/nns_contract.go b/nns/nns_contract.go
index 56dfb2c..7396cc3 100644
--- a/nns/nns_contract.go
+++ b/nns/nns_contract.go
@@ -527,6 +527,50 @@ func deleteRecords(ctx storage.Context, name string, typ RecordType) {
runtime.Notify("DeleteRecords", name, typ)
}
+// DeleteRecord delete a record of the specified type by data in the provided domain.
+// Returns false if the record was not found.
+func DeleteRecord(name string, typ RecordType, data string) bool {
+ tokenID := []byte(tokenIDFromName(name))
+ if !checkBaseRecords(typ, data) {
+ panic("invalid record data")
+ }
+ ctx := storage.GetContext()
+ ns := getNameState(ctx, tokenID)
+ ns.checkAdmin()
+ return deleteRecord(ctx, tokenID, name, typ, data)
+}
+
+func deleteRecord(ctx storage.Context, tokenId []byte, name string, typ RecordType, data string) bool {
+ recordsKey := getRecordsKeyByType(tokenId, name, typ)
+
+ var previousKey any
+ it := storage.Find(ctx, recordsKey, storage.KeysOnly)
+ for iterator.Next(it) {
+ key := iterator.Value(it).([]byte)
+ ss := storage.Get(ctx, key).([]byte)
+
+ ns := std.Deserialize(ss).(RecordState)
+ if ns.Name == name && ns.Type == typ && ns.Data == data {
+ previousKey = key
+ continue
+ }
+
+ if previousKey != nil {
+ data := storage.Get(ctx, key)
+ storage.Put(ctx, previousKey, data)
+ previousKey = key
+ }
+ }
+
+ if previousKey == nil {
+ return false
+ }
+
+ storage.Delete(ctx, previousKey)
+ runtime.Notify("DeleteRecord", name, typ)
+ return true
+}
+
// DeleteDomain deletes the domain with the given name.
func DeleteDomain(name string) {
ctx := storage.GetContext()
@@ -534,13 +578,25 @@ func DeleteDomain(name string) {
}
func deleteDomain(ctx storage.Context, name string) {
- nameKey := append([]byte{prefixName}, getTokenKey([]byte(name))...)
- tldBytes := storage.Get(ctx, nameKey)
- if tldBytes == nil {
- return
+ it := Tokens()
+ for iterator.Next(it) {
+ domain := iterator.Value(it)
+ if std.MemorySearch([]byte(domain.(string)), []byte(name)) > 0 {
+ panic("can't delete a domain that has subdomains")
+ }
}
- globalDomainRaw := storage.Get(ctx, append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...))
+ nsKey := append([]byte{prefixName}, getTokenKey([]byte(name))...)
+ nsRaw := storage.Get(ctx, nsKey)
+ if nsRaw == nil {
+ panic("domain not found")
+ }
+
+ ns := std.Deserialize(nsRaw.([]byte)).(NameState)
+ ns.checkAdmin()
+
+ globalNSKey := append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...)
+ globalDomainRaw := storage.Get(ctx, globalNSKey)
globalDomain := globalDomainRaw.(string)
if globalDomainRaw != nil && globalDomain != "" {
deleteDomain(ctx, globalDomain)
@@ -550,7 +606,8 @@ func deleteDomain(ctx storage.Context, name string) {
deleteRecords(ctx, name, TXT)
deleteRecords(ctx, name, A)
deleteRecords(ctx, name, AAAA)
- storage.Delete(ctx, nameKey)
+ storage.Delete(ctx, nsKey)
+ storage.Delete(ctx, append([]byte{prefixRoot}, []byte(name)...))
runtime.Notify("DeleteDomain", name)
}
diff --git a/rpcclient/nns/client.go b/rpcclient/nns/client.go
index b2cfa46..00192bc 100644
--- a/rpcclient/nns/client.go
+++ b/rpcclient/nns/client.go
@@ -29,6 +29,12 @@ type AddRecordEvent struct {
Type *big.Int
}
+// DeleteRecordEvent represents "DeleteRecord" event emitted by the contract.
+type DeleteRecordEvent struct {
+ Name string
+ Type *big.Int
+}
+
// DeleteRecordsEvent represents "DeleteRecords" event emitted by the contract.
type DeleteRecordsEvent struct {
Name string
@@ -168,6 +174,44 @@ func (c *Contract) DeleteDomainUnsigned(name string) (*transaction.Transaction,
return c.actor.MakeUnsignedCall(c.hash, "deleteDomain", nil, name)
}
+func (c *Contract) scriptForDeleteRecord(name string, typ *big.Int, data string) ([]byte, error) {
+ return smartcontract.CreateCallWithAssertScript(c.hash, "deleteRecord", name, typ, data)
+}
+
+// DeleteRecord creates a transaction invoking `deleteRecord` 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) DeleteRecord(name string, typ *big.Int, data string) (util.Uint256, uint32, error) {
+ script, err := c.scriptForDeleteRecord(name, typ, data)
+ if err != nil {
+ return util.Uint256{}, 0, err
+ }
+ return c.actor.SendRun(script)
+}
+
+// DeleteRecordTransaction creates a transaction invoking `deleteRecord` method of the contract.
+// This transaction is signed, but not sent to the network, instead it's
+// returned to the caller.
+func (c *Contract) DeleteRecordTransaction(name string, typ *big.Int, data string) (*transaction.Transaction, error) {
+ script, err := c.scriptForDeleteRecord(name, typ, data)
+ if err != nil {
+ return nil, err
+ }
+ return c.actor.MakeRun(script)
+}
+
+// DeleteRecordUnsigned creates a transaction invoking `deleteRecord` 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) DeleteRecordUnsigned(name string, typ *big.Int, data string) (*transaction.Transaction, error) {
+ script, err := c.scriptForDeleteRecord(name, typ, data)
+ if err != nil {
+ return nil, err
+ }
+ return c.actor.MakeUnsignedRun(script, nil)
+}
+
// DeleteRecords creates a transaction invoking `deleteRecords` 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.
@@ -510,6 +554,73 @@ func (e *AddRecordEvent) FromStackItem(item *stackitem.Array) error {
return nil
}
+// DeleteRecordEventsFromApplicationLog retrieves a set of all emitted events
+// with "DeleteRecord" name from the provided [result.ApplicationLog].
+func DeleteRecordEventsFromApplicationLog(log *result.ApplicationLog) ([]*DeleteRecordEvent, error) {
+ if log == nil {
+ return nil, errors.New("nil application log")
+ }
+
+ var res []*DeleteRecordEvent
+ for i, ex := range log.Executions {
+ for j, e := range ex.Events {
+ if e.Name != "DeleteRecord" {
+ continue
+ }
+ event := new(DeleteRecordEvent)
+ err := event.FromStackItem(e.Item)
+ if err != nil {
+ return nil, fmt.Errorf("failed to deserialize DeleteRecordEvent from stackitem (execution #%d, event #%d): %w", i, j, err)
+ }
+ res = append(res, event)
+ }
+ }
+
+ return res, nil
+}
+
+// FromStackItem converts provided [stackitem.Array] to DeleteRecordEvent or
+// returns an error if it's not possible to do to so.
+func (e *DeleteRecordEvent) 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) != 2 {
+ return errors.New("wrong number of structure elements")
+ }
+
+ var (
+ index = -1
+ err error
+ )
+ index++
+ e.Name, err = func(item stackitem.Item) (string, error) {
+ b, err := item.TryBytes()
+ if err != nil {
+ return "", err
+ }
+ if !utf8.Valid(b) {
+ return "", errors.New("not a UTF-8 string")
+ }
+ return string(b), nil
+ }(arr[index])
+ if err != nil {
+ return fmt.Errorf("field Name: %w", err)
+ }
+
+ index++
+ e.Type, err = arr[index].TryInteger()
+ if err != nil {
+ return fmt.Errorf("field Type: %w", err)
+ }
+
+ return nil
+}
+
// DeleteRecordsEventsFromApplicationLog retrieves a set of all emitted events
// with "DeleteRecords" name from the provided [result.ApplicationLog].
func DeleteRecordsEventsFromApplicationLog(log *result.ApplicationLog) ([]*DeleteRecordsEvent, error) {
diff --git a/tests/nns_test.go b/tests/nns_test.go
index 6c111b0..3e2dcf6 100644
--- a/tests/nns_test.go
+++ b/tests/nns_test.go
@@ -178,6 +178,47 @@ func TestNNSRegister(t *testing.T) {
c.Invoke(t, stackitem.Null{}, "getRecords", "testdomain.com", int64(nns.TXT))
+ cAcc.Invoke(t, stackitem.Null{}, "addRecord",
+ "testdomain.com", int64(nns.TXT), "rec1")
+
+ cAcc.Invoke(t, stackitem.Null{}, "addRecord",
+ "testdomain.com", int64(nns.TXT), "rec2")
+
+ cAcc.Invoke(t, stackitem.Null{}, "addRecord",
+ "testdomain.com", int64(nns.TXT), "rec3")
+
+ cAcc.Invoke(t, stackitem.Null{}, "addRecord",
+ "testdomain.com", int64(nns.TXT), "rec4")
+
+ cAcc.Invoke(t, false, "deleteRecord",
+ "testdomain.com", int64(nns.TXT), "rec9999")
+
+ cAcc.Invoke(t, true, "deleteRecord",
+ "testdomain.com", int64(nns.TXT), "rec1")
+
+ expected = stackitem.NewArray([]stackitem.Item{
+ stackitem.NewByteArray([]byte("rec2")),
+ stackitem.NewByteArray([]byte("rec3")),
+ stackitem.NewByteArray([]byte("rec4")),
+ })
+ c.Invoke(t, expected, "getRecords", "testdomain.com", int64(nns.TXT))
+
+ cAcc.Invoke(t, true, "deleteRecord",
+ "testdomain.com", int64(nns.TXT), "rec4")
+
+ cAcc.Invoke(t, true, "deleteRecord",
+ "testdomain.com", int64(nns.TXT), "rec2")
+
+ expected = stackitem.NewArray([]stackitem.Item{
+ stackitem.NewByteArray([]byte("rec3")),
+ })
+ c.Invoke(t, expected, "getRecords", "testdomain.com", int64(nns.TXT))
+
+ cAcc.Invoke(t, true, "deleteRecord",
+ "testdomain.com", int64(nns.TXT), "rec3")
+
+ c.Invoke(t, stackitem.Null{}, "getRecords", "testdomain.com", int64(nns.TXT))
+
tx = cAcc.Invoke(t, stackitem.Null{}, "deleteDomain", "testdomain.com")
expected = stackitem.NewArray([]stackitem.Item{stackitem.NewByteArray([]byte("testdomain.com")),
stackitem.NewBigInteger(big.NewInt(int64(nns.CNAME)))})
@@ -189,6 +230,44 @@ func TestNNSRegister(t *testing.T) {
c.InvokeFail(t, "token not found", "getRecords", "testdomain.com", int64(nns.SOA))
}
+func TestDeleteDomain(t *testing.T) {
+ c := newNNSInvoker(t, false)
+
+ acc1 := c.NewAccount(t)
+ c1 := c.WithSigners(c.Committee, acc1)
+
+ acc2 := c.NewAccount(t)
+ c2 := c.WithSigners(c.Committee, acc2)
+ c1.Invoke(t, true, "register",
+ "com", acc1.ScriptHash(),
+ "myemail@frostfs.info", defaultRefresh, defaultRetry, defaultExpire, defaultTTL)
+
+ c1.Invoke(t, true, "register",
+ "testdomain.com", acc1.ScriptHash(),
+ "myemail@frostfs.info", defaultRefresh, defaultRetry, defaultExpire, defaultTTL)
+
+ c1.Invoke(t, true, "register",
+ "domik.testdomain.com", acc1.ScriptHash(),
+ "myemail@frostfs.info", defaultRefresh, defaultRetry, defaultExpire, defaultTTL)
+
+ c1.InvokeFail(t, "domain not found", "deleteDomain", "ru")
+ c1.InvokeFail(t, "can't delete a domain that has subdomains", "deleteDomain", "testdomain.com")
+ c1.Invoke(t, stackitem.Null{}, "deleteDomain", "domik.testdomain.com")
+ c1.Invoke(t, stackitem.Null{}, "deleteDomain", "testdomain.com")
+
+ c1.Invoke(t, true, "register",
+ "cn", acc1.ScriptHash(),
+ "myemail@frostfs.info", defaultRefresh, defaultRetry, defaultExpire, defaultTTL)
+
+ c2.InvokeFail(t, "not witnessed by admin", "deleteDomain", "cn")
+
+ c1.Invoke(t, stackitem.Null{}, "deleteDomain", "cn")
+
+ c2.Invoke(t, true, "register",
+ "cn", acc2.ScriptHash(),
+ "myemail@frostfs.info", defaultRefresh, defaultRetry, defaultExpire, defaultTTL)
+}
+
func TestGlobalDomain(t *testing.T) {
c := newNNSInvoker(t, false)