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)