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 2eb53c3..678a341 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,88 @@ func IsAvailable(name string) bool { } return true } + checkParentExists(ctx, fragments) + checkAvailableGlobalDomain(ctx, name) + + var id byte + tokenId := []byte(tokenIDFromName(name)) + recordKey := getIdRecordKey(tokenId, name, CNAME, id) + cname := storage.Get(ctx, recordKey) + + if cname != nil { + return false + } 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) + } + + var id byte + tokenId := []byte(tokenIDFromName(globalDomain)) + recordKey := getIdRecordKey(tokenId, globalDomain, CNAME, id) + cname := storage.Get(ctx, recordKey) + if cname != 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 != "" { @@ -325,6 +412,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) @@ -425,6 +514,20 @@ func DeleteRecords(name string, typ RecordType) { ctx := storage.GetContext() 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 != "" { + var id byte + tokenId := []byte(tokenIDFromName(globalDomain)) + recordKey := getIdRecordKey(tokenId, globalDomain, CNAME, id) + if recordKey != nil { + storage.Delete(ctx, recordKey) + } + } + storage.Delete(ctx, globalDomainStorage) + recordsKey := getRecordsKeyByType(tokenID, name, typ) records := storage.Find(ctx, recordsKey, storage.KeysOnly) for iterator.Next(records) { @@ -574,6 +677,20 @@ 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) + putCnameRecord(ctx, globalDomain, name) + storage.Put(ctx, globalDomainKey, globalDomain) + } else { + storage.Put(ctx, globalDomainKey, "") + } + } + if typ == CNAME && id != 0 { panic("you shouldn't have more than one CNAME record") } @@ -614,6 +731,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 diff --git a/tests/container_test.go b/tests/container_test.go index 035910e..b331cd7 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,60 @@ 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(0), int64(0), int64(100_000), int64(0)) + + cNNS.Invoke(t, true, "register", + "ns", c.CommitteeHash, + "whateveriwant@world.com", int64(0), int64(0), int64(100_000), int64(0)) + + cNNS.Invoke(t, true, "register", + "poland.ns", c.CommitteeHash, + "whateveriwant@world.com", int64(0), int64(0), int64(100_000), int64(0)) + + cNNS.Invoke(t, true, "register", + "sweden.ns", c.CommitteeHash, + "whateveriwant@world.com", int64(0), int64(0), int64(100_000), int64(0)) + + 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") + }) + 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)) }