diff --git a/nns/doc.go b/nns/doc.go index fcce7fb..787cca4 100644 --- a/nns/doc.go +++ b/nns/doc.go @@ -11,6 +11,7 @@ | 0x20 | int | set of roots | | 0x21 + tokenKey | ByteArray | serialized NameState struct | | 0x22 + tokenKey + Hash160(tokenName) | Hash160 | container contract hash | + | 0x23 + tokenKey + Hash160(tokenName) | string | global domain flag | */ diff --git a/nns/nns_contract.go b/nns/nns_contract.go index d17212c..e49b1fc 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -41,6 +41,9 @@ const ( // prefixRecord contains map from (token key + hash160(token name) + record type) // to record. prefixRecord byte = 0x22 + //prefixGlobalDomain contains a flag indicating that this domain was created using GlobalDomain. + //This is necessary to distinguish it from regular CNAME records. + prefixGlobalDomain byte = 0x23 ) // Values constraints. @@ -69,6 +72,12 @@ const ( errInvalidDomainName = "invalid domain name format" ) +const ( + // Cnametgt is a special TXT record ensuring all created subdomains point to the global domain - the value of this variable. + //It is guaranteed that two domains cannot point to the same global domain. + Cnametgt = "cnametgt" +) + // RecordState is a type that registered entities are saved to. type RecordState struct { Name string @@ -230,10 +239,71 @@ func IsAvailable(name string) bool { } return true } + checkParentExists(ctx, fragments) + checkAvailableGlobalDomain(ctx, name) return storage.Get(ctx, append([]byte{prefixName}, getTokenKey([]byte(name))...)) == nil } +// checkAvailableGlobalDomain - triggers a panic if the global domain name is occupied. +func checkAvailableGlobalDomain(ctx storage.Context, domain string) { + globalDomain := getGlobalDomain(ctx, domain) + if globalDomain == "" { + return + } + + nsBytes := storage.Get(ctx, append([]byte{prefixName}, getTokenKey([]byte(globalDomain))...)) + if nsBytes != nil { + panic("global domain is already taken: " + globalDomain + ". Domain: " + domain) + } +} + +// getGlobalDomain returns the global domain. +func getGlobalDomain(ctx storage.Context, domain string) string { + index := std.MemorySearch([]byte(domain), []byte(".")) + + if index == -1 { + return "" + } + + name := domain[index+1:] + if name == "" { + return "" + } + + return extractCnametgt(ctx, name, domain) +} + +// extractCnametgt returns the value of the Cnametgt TXT record. +func extractCnametgt(ctx storage.Context, name, domain string) string { + fragments := splitAndCheck(domain) + + tokenID := []byte(tokenIDFromName(name)) + records := getRecordsByType(ctx, tokenID, name, TXT) + + if records == nil { + return "" + } + + globalDomain := "" + for _, name := range records { + fragments := std.StringSplit(name, "=") + if len(fragments) != 2 { + continue + } + + if fragments[0] == Cnametgt { + globalDomain = fragments[1] + break + } + } + + if globalDomain == "" { + return "" + } + return fragments[0] + "." + globalDomain +} + // checkParentExists panics if any domain from fragments doesn't exist or is expired. func checkParentExists(ctx storage.Context, fragments []string) { if dom := parentExpired(ctx, fragments); dom != "" { @@ -265,10 +335,16 @@ func parentExpired(ctx storage.Context, fragments []string) string { // Register registers a new domain with the specified owner and name if it's available. func Register(name string, owner interop.Hash160, email string, refresh, retry, expire, ttl int) bool { + ctx := storage.GetContext() + return register(ctx, name, owner, email, refresh, retry, expire, ttl) +} + +// Register registers a new domain with the specified owner and name if it's available. +func register(ctx storage.Context, name string, owner interop.Hash160, email string, refresh, retry, expire, ttl int) bool { fragments := splitAndCheck(name) l := len(fragments) tldKey := append([]byte{prefixRoot}, []byte(fragments[l-1])...) - ctx := storage.GetContext() + tldBytes := storage.Get(ctx, tldKey) if l == 1 { checkCommittee() @@ -325,6 +401,8 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, // NNS expiration is in milliseconds Expiration: int64(runtime.GetTime() + expire*1000), } + checkAvailableGlobalDomain(ctx, name) + putNameStateWithKey(ctx, tokenKey, ns) putSoaRecord(ctx, name, email, refresh, retry, expire, ttl) updateBalance(ctx, []byte(name), owner, +1) @@ -422,6 +500,7 @@ func DeleteRecords(name string, typ RecordType) { deleteRecords(ctx, name, typ) } +// DeleteRecords removes domain records with the specified type. func deleteRecords(ctx storage.Context, name string, typ RecordType) { if typ == SOA { panic("you cannot delete soa record") @@ -429,6 +508,14 @@ func deleteRecords(ctx storage.Context, name string, typ RecordType) { tokenID := []byte(tokenIDFromName(name)) ns := getNameState(ctx, tokenID) ns.checkAdmin() + + globalDomainStorage := append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...) + globalDomainRaw := storage.Get(ctx, globalDomainStorage) + globalDomain := globalDomainRaw.(string) + if globalDomainRaw != nil && globalDomain != "" { + deleteDomain(ctx, globalDomain) + } + recordsKey := getRecordsKeyByType(tokenID, name, typ) records := storage.Find(ctx, recordsKey, storage.KeysOnly) for iterator.Next(records) { @@ -450,6 +537,12 @@ func deleteDomain(ctx storage.Context, name string) { return } + globalDomainRaw := storage.Get(ctx, append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...)) + globalDomain := globalDomainRaw.(string) + if globalDomainRaw != nil && globalDomain != "" { + deleteDomain(ctx, globalDomain) + } + deleteRecords(ctx, name, CNAME) deleteRecords(ctx, name, TXT) deleteRecords(ctx, name, A) @@ -597,6 +690,33 @@ func addRecord(ctx storage.Context, tokenId []byte, name string, typ RecordType, } } + globalDomainKey := append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...) + globalDomainStorage := storage.Get(ctx, globalDomainKey) + globalDomain := getGlobalDomain(ctx, name) + + if globalDomainStorage == nil && typ == TXT { + if globalDomain != "" { + checkAvailableGlobalDomain(ctx, name) + nsOriginal := getNameState(ctx, []byte(tokenIDFromName(name))) + ns := NameState{ + Name: globalDomain, + Owner: nsOriginal.Owner, + Expiration: nsOriginal.Expiration, + Admin: nsOriginal.Admin, + } + + putNameStateWithKey(ctx, getTokenKey([]byte(globalDomain)), ns) + storage.Put(ctx, globalDomainKey, globalDomain) + + var oldOwner interop.Hash160 + updateBalance(ctx, []byte(name), nsOriginal.Owner, +1) + postTransfer(oldOwner, nsOriginal.Owner, []byte(name), nil) + putCnameRecord(ctx, globalDomain, name) + } else { + storage.Put(ctx, globalDomainKey, "") + } + } + if typ == CNAME && id != 0 { panic("you shouldn't have more than one CNAME record") } @@ -637,6 +757,22 @@ func putSoaRecord(ctx storage.Context, name, email string, refresh, retry, expir storage.Put(ctx, recordKey, recBytes) } +// putCnameRecord stores CNAME domain record. +func putCnameRecord(ctx storage.Context, name, data string) { + var id byte + tokenId := []byte(tokenIDFromName(name)) + recordKey := getIdRecordKey(tokenId, name, CNAME, id) + + rs := RecordState{ + Name: name, + Type: CNAME, + ID: id, + Data: data, + } + recBytes := std.Serialize(rs) + storage.Put(ctx, recordKey, recBytes) +} + // updateSoaSerial stores soa domain record. func updateSoaSerial(ctx storage.Context, tokenId []byte) { var id byte @@ -644,7 +780,7 @@ func updateSoaSerial(ctx storage.Context, tokenId []byte) { recBytes := storage.Get(ctx, recordKey) if recBytes == nil { - panic("not found soa record") + return } rec := std.Deserialize(recBytes.([]byte)).(RecordState) diff --git a/tests/container_test.go b/tests/container_test.go index 035910e..f1cd07d 100644 --- a/tests/container_test.go +++ b/tests/container_test.go @@ -165,6 +165,14 @@ func checkContainerList(t *testing.T, c *neotest.ContractInvoker, expected [][]b }) } +const ( + // default SOA record field values + defaultRefresh = 3600 // 1 hour + defaultRetry = 600 // 10 min + defaultExpire = 3600 * 24 * 365 * 10 // 10 years + defaultTTL = 3600 // 1 hour +) + func TestContainerPut(t *testing.T) { c, cBal, _ := newContainerInvoker(t) @@ -233,6 +241,63 @@ func TestContainerPut(t *testing.T) { }) }) + t.Run("create global domain", func(t *testing.T) { + ctrNNS := neotest.CompileFile(t, c.CommitteeHash, nnsPath, path.Join(nnsPath, "config.yml")) + nnsHash := ctrNNS.Hash + cNNS := c.CommitteeInvoker(nnsHash) + cNNS.Invoke(t, true, "register", + "animals", c.CommitteeHash, + "whateveriwant@world.com", int64(defaultRefresh), int64(defaultRetry), int64(defaultExpire), int64(defaultTTL)) + + cNNS.Invoke(t, true, "register", + "ns", c.CommitteeHash, + "whateveriwant@world.com", int64(defaultRefresh), int64(defaultRetry), int64(defaultExpire), int64(0)) + + cNNS.Invoke(t, true, "register", + "poland.ns", c.CommitteeHash, + "whateveriwant@world.com", int64(defaultRefresh), int64(defaultRetry), int64(defaultExpire), int64(0)) + + cNNS.Invoke(t, true, "register", + "sweden.ns", c.CommitteeHash, + "whateveriwant@world.com", int64(defaultRefresh), int64(defaultRetry), int64(defaultExpire), int64(defaultExpire)) + + cNNS.Invoke(t, stackitem.Null{}, "addRecord", + "poland.ns", int64(nns.TXT), nns.Cnametgt+"=animals") + + cNNS.Invoke(t, stackitem.Null{}, "addRecord", "poland.ns", int64(nns.TXT), "random-record") + + cNNS.Invoke(t, stackitem.Null{}, "addRecord", "poland.ns", int64(nns.TXT), "ne-qqq=random-record2") + cNNS.Invoke(t, stackitem.Null{}, "addRecord", "sweden.ns", int64(nns.TXT), nns.Cnametgt+"=animals") + + balanceMint(t, cBal, acc, (containerFee+containerAliasFee)*5, []byte{}) + cNNS.Invoke(t, true, "isAvailable", "bober.animals") + putArgs := []any{cnt.value, cnt.sig, cnt.pub, cnt.token, "bober", "poland.ns"} + c3 := c.WithSigners(c.Committee, acc) + c3.Invoke(t, stackitem.Null{}, "putNamed", putArgs...) + + cNNS.Invoke(t, false, "isAvailable", "bober.animals") + + putArgs = []any{cnt.value, cnt.sig, cnt.pub, cnt.token, "bober", "sweden.ns"} + + c3.InvokeFail(t, "global domain is already taken", "putNamed", putArgs...) + cNNS.InvokeFail(t, "global domain is already taken", "isAvailable", "bober.poland.ns") + + cnt2 := dummyContainer(acc) + cNNS.Invoke(t, true, "isAvailable", "uzik.poland.ns") + putArgs = []any{cnt2.value, cnt2.sig, cnt2.pub, cnt2.token, "uzik", "poland.ns"} + c3.Invoke(t, stackitem.Null{}, "putNamed", putArgs...) + + c3.Invoke(t, stackitem.Null{}, "delete", cnt.id[:], cnt.sig, cnt.pub, cnt.token) + cNNS.Invoke(t, true, "isAvailable", "bober.animals") + cNNS.Invoke(t, false, "isAvailable", "bober.poland.ns") + + cNNS.InvokeFail(t, "global domain is already taken", "isAvailable", "uzik.poland.ns") + cNNS.Invoke(t, false, "isAvailable", "uzik.animals") + + records := stackitem.NewArray([]stackitem.Item{stackitem.NewBuffer([]byte("uzik.poland.ns")), stackitem.NewByteArray([]byte(base58.Encode(cnt2.id[:])))}) + cNNS.Invoke(t, records, "resolve", "uzik.animals", int64(nns.TXT)) + }) + t.Run("gas costs are the same for all containers in block", func(t *testing.T) { const ( containerPerBlock = 512 diff --git a/tests/nns_test.go b/tests/nns_test.go index c27d0a1..e824303 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -159,6 +159,49 @@ func TestNNSRegister(t *testing.T) { c.Invoke(t, expected, "getRecords", "testdomain.com", int64(nns.TXT)) } +func TestGlobalDomain(t *testing.T) { + c := newNNSInvoker(t, false) + + accTop := c.NewAccount(t) + refresh, retry, expire, ttl := int64(101), int64(102), int64(103), int64(104) + c1 := c.WithSigners(c.Committee, accTop) + c1.Invoke(t, true, "register", + "com", accTop.ScriptHash(), + "myemail@frostfs.info", refresh, retry, expire, ttl) + + c1.Invoke(t, true, "register", + "testdomain.com", accTop.ScriptHash(), + "myemail@frostfs.info", refresh, retry, expire, ttl) + + c1.Invoke(t, true, "register", + "globaldomain.com", accTop.ScriptHash(), + "myemail@frostfs.info", refresh, retry, expire, ttl) + + c1.Invoke(t, true, "register", + "domik.testdomain.com", accTop.ScriptHash(), + "myemail@frostfs.info", refresh, retry, expire, ttl) + c1.Invoke(t, stackitem.Null{}, "addRecord", + "domik.testdomain.com", int64(nns.TXT), "CID") + + c.Invoke(t, true, "isAvailable", "domik.globaldomain.com") + + c1.Invoke(t, stackitem.Null{}, "addRecord", + "testdomain.com", int64(nns.TXT), nns.Cnametgt+"=globaldomain.com") + c.Invoke(t, true, "isAvailable", "dom.testdomain.com") + + c1.Invoke(t, stackitem.Null{}, "addRecord", + "domik.testdomain.com", int64(nns.TXT), "random txt record") + c.Invoke(t, true, "isAvailable", "domik.globaldomain.com") + + c1.Invoke(t, true, "register", + "dom.testdomain.com", accTop.ScriptHash(), + "myemail@frostfs.info", refresh, retry, expire, ttl) + + c1.Invoke(t, stackitem.Null{}, "addRecord", + "dom.testdomain.com", int64(nns.TXT), "CID") + + c.InvokeFail(t, "global domain is already taken", "isAvailable", "dom.testdomain.com") +} func TestTLDRecord(t *testing.T) { c := newNNSInvoker(t, true) c.Invoke(t, stackitem.Null{}, "addRecord", @@ -365,6 +408,10 @@ func TestNNSIsAvailable(t *testing.T) { "domain.com", acc.ScriptHash(), "myemail@frostfs.info", refresh, retry, expire, ttl) + c1.Invoke(t, true, "register", + "globaldomain.com", acc.ScriptHash(), + "myemail@frostfs.info", refresh, retry, expire, ttl) + c.Invoke(t, false, "isAvailable", "domain.com") c.Invoke(t, true, "isAvailable", "dom.domain.com") @@ -376,6 +423,16 @@ func TestNNSIsAvailable(t *testing.T) { c.Invoke(t, false, "isAvailable", "dom.domain.com") c.Invoke(t, true, "isAvailable", "dom.dom.domain.com") + c1.Invoke(t, stackitem.Null{}, "addRecord", + "dom.domain.com", int64(nns.TXT), nns.Cnametgt+"=globaldomain.com") + c.Invoke(t, true, "isAvailable", "dom.dom.domain.com") + + c1.Invoke(t, true, "register", + "dom.globaldomain.com", acc.ScriptHash(), + "myemail@frostfs.info", refresh, retry, expire, ttl) + + c.InvokeFail(t, "global domain is already taken", "isAvailable", "dom.dom.domain.com") + c.InvokeFail(t, "domain name too long", "isAvailable", getTooLongDomainName(255)) }