diff --git a/nns/doc.go b/nns/doc.go index fcce7fb..6ff0a71 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) | bool | global domain flag | */ diff --git a/nns/global_domain.go b/nns/global_domain.go new file mode 100644 index 0000000..83a500d --- /dev/null +++ b/nns/global_domain.go @@ -0,0 +1,26 @@ +package nns + +import "github.com/nspcc-dev/neo-go/pkg/interop/storage" + +// GlobalDomainInfo provides domain details including global zone aliases and activation status. +func GlobalDomainInfo(name string) string { + fragments := splitAndCheck(name) + ctx := storage.GetReadOnlyContext() + globalDomainKey := append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...) + status := storage.Get(ctx, globalDomainKey) + + if status != nil { + tokenId := []byte(tokenIDFromName(name)) + globalDomain := getRecordsByType(ctx, tokenId, name, CNAME) + if len(globalDomain) == 0 { + return "" + } + return globalDomain[0] + } + + cnameTgt := extractCnametgt(ctx, name, fragments) + if cnameTgt == "" { + return "" + } + return "." + cnameTgt +} diff --git a/nns/nns_contract.go b/nns/nns_contract.go index 2eb53c3..a236f8c 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 @@ -231,9 +240,84 @@ func IsAvailable(name string) bool { return true } checkParentExists(ctx, fragments) + if !checkAvailableGlobalDomain(ctx, fragments) { + panic("global domain is already taken") + } return storage.Get(ctx, append([]byte{prefixName}, getTokenKey([]byte(name))...)) == nil } +// CheckAvailableGlobalDomain - triggers a panic if the global domain name is occupied. +func CheckAvailableGlobalDomain(name string) { + fragments := splitAndCheck(name) + ctx := storage.GetReadOnlyContext() + if !checkAvailableGlobalDomain(ctx, fragments) { + panic("global domain is already taken") + } +} + +func checkAvailableGlobalDomain(ctx storage.Context, fragments []string) bool { + globalDomain := getGlobalDomain(ctx, fragments) + if globalDomain == "" { + return true + } + + nsBytes := storage.Get(ctx, append([]byte{prefixName}, getTokenKey([]byte(globalDomain))...)) + if nsBytes != nil { + return false + } + return true +} + +// GetGlobalDomain returns the global domain. +func GetGlobalDomain(name string) string { + fragments := splitAndCheck(name) + ctx := storage.GetReadOnlyContext() + return getGlobalDomain(ctx, fragments) +} + +func getGlobalDomain(ctx storage.Context, fragments []string) string { + name := "" + for i := 1; i < len(fragments); i++ { + if name != "" { + name = name + "." + } + name = name + fragments[i] + } + + if name == "" { + return "" + } + + return extractCnametgt(ctx, name, fragments) +} + +// extractCnametgt returns the value of the Cnametgt TXT record. +func extractCnametgt(ctx storage.Context, name string, fragments []string) string { + 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] + } + } + + 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 +349,14 @@ 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) +} + +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 +413,10 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, // NNS expiration is in milliseconds Expiration: int64(runtime.GetTime() + expire*1000), } + if !checkAvailableGlobalDomain(ctx, fragments) { + panic("global domain is already taken") + } + putNameStateWithKey(ctx, tokenKey, ns) putSoaRecord(ctx, name, email, refresh, retry, expire, ttl) updateBalance(ctx, []byte(name), owner, +1) @@ -332,6 +424,32 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, return true } +func DeleteDomain(name string) { + ctx := storage.GetContext() + deleteDomain(ctx, name) +} + +func deleteDomain(ctx storage.Context, name string) { + nameKey := append([]byte{prefixName}, getTokenKey([]byte(name))...) + tldBytes := storage.Get(ctx, nameKey) + if tldBytes == nil { + return + } + + tokenId := []byte(tokenIDFromName(name)) + globalDomain := getRecordsByType(ctx, tokenId, name, CNAME) + if len(globalDomain) > 0 { + DeleteRecords(name, CNAME) + globalDomainKey := append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...) + status := storage.Get(ctx, globalDomainKey) + if status != nil { + deleteDomain(ctx, globalDomain[0]) + } + } + DeleteRecords(name, TXT) + storage.Delete(ctx, nameKey) +} + // Renew increases domain expiration date. func Renew(name string) int64 { checkDomainNameLength(name) @@ -427,6 +545,20 @@ func DeleteRecords(name string, typ RecordType) { ns.checkAdmin() recordsKey := getRecordsKeyByType(tokenID, name, typ) records := storage.Find(ctx, recordsKey, storage.KeysOnly) + + if typ == TXT { + fragments := splitAndCheck(name) + globalDomain := getGlobalDomain(ctx, fragments) + globalDomainKey := append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...) + status := storage.Get(ctx, globalDomainKey) + + if status != nil || globalDomain != "" { + storage.Delete(ctx, globalDomainKey) + deleteDomain(ctx, globalDomain) + DeleteRecords(name, CNAME) + } + } + for iterator.Next(records) { r := iterator.Value(records).(string) storage.Delete(ctx, r) @@ -574,6 +706,24 @@ func addRecord(ctx storage.Context, tokenId []byte, name string, typ RecordType, } } + globalDomainKey := append([]byte{prefixGlobalDomain}, getTokenKey([]byte(name))...) + status := storage.Get(ctx, globalDomainKey) + fragments := splitAndCheck(name) + globalDomain := getGlobalDomain(ctx, fragments) + + if status == nil && globalDomain != "" { + var id byte + recordKey := getIdRecordKey(tokenId, string(tokenId), SOA, id) + + recBytes := storage.Get(ctx, recordKey) + rec := std.Deserialize(recBytes.([]byte)).(RecordState) + split := std.StringSplitNonEmpty(rec.Data, " ") + register(ctx, globalDomain, OwnerOf(tokenId), split[1], std.Atoi(split[3], 10), std.Atoi(split[4], 10), + std.Atoi(split[5], 10), std.Atoi(split[6], 10)) + + storage.Put(ctx, globalDomainKey, true) + putCnameRecord(ctx, name, globalDomain) + } if typ == CNAME && id != 0 { panic("you shouldn't have more than one CNAME record") } @@ -614,6 +764,21 @@ 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/rpcclient/nns/client.go b/rpcclient/nns/client.go index b929222..e10711c 100644 --- a/rpcclient/nns/client.go +++ b/rpcclient/nns/client.go @@ -70,6 +70,11 @@ func (c *ContractReader) GetRecords(name string, typ *big.Int) ([]stackitem.Item return unwrap.Array(c.invoker.Call(c.hash, "getRecords", name, typ)) } +// GlobalDomainInfo invokes `globalDomainInfo` method of contract. +func (c *ContractReader) GlobalDomainInfo(name string) (string, error) { + return unwrap.UTF8String(c.invoker.Call(c.hash, "globalDomainInfo", name)) +} + // IsAvailable invokes `isAvailable` method of contract. func (c *ContractReader) IsAvailable(name string) (bool, error) { return unwrap.Bool(c.invoker.Call(c.hash, "isAvailable", name)) @@ -121,6 +126,50 @@ func (c *Contract) AddRecordUnsigned(name string, typ *big.Int, data string) (*t return c.actor.MakeUnsignedCall(c.hash, "addRecord", nil, name, typ, data) } +// CheckAvailableGlobalDomain creates a transaction invoking `checkAvailableGlobalDomain` 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) CheckAvailableGlobalDomain(name string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "checkAvailableGlobalDomain", name) +} + +// CheckAvailableGlobalDomainTransaction creates a transaction invoking `checkAvailableGlobalDomain` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) CheckAvailableGlobalDomainTransaction(name string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "checkAvailableGlobalDomain", name) +} + +// CheckAvailableGlobalDomainUnsigned creates a transaction invoking `checkAvailableGlobalDomain` 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) CheckAvailableGlobalDomainUnsigned(name string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "checkAvailableGlobalDomain", nil, name) +} + +// DeleteDomain creates a transaction invoking `deleteDomain` 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) DeleteDomain(name string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "deleteDomain", name) +} + +// DeleteDomainTransaction creates a transaction invoking `deleteDomain` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) DeleteDomainTransaction(name string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "deleteDomain", name) +} + +// DeleteDomainUnsigned creates a transaction invoking `deleteDomain` 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) DeleteDomainUnsigned(name string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "deleteDomain", nil, name) +} + // 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. @@ -165,6 +214,28 @@ func (c *Contract) GetAllRecordsUnsigned(name string) (*transaction.Transaction, return c.actor.MakeUnsignedCall(c.hash, "getAllRecords", nil, name) } +// GetGlobalDomain creates a transaction invoking `getGlobalDomain` 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) GetGlobalDomain(name string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "getGlobalDomain", name) +} + +// GetGlobalDomainTransaction creates a transaction invoking `getGlobalDomain` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) GetGlobalDomainTransaction(name string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "getGlobalDomain", name) +} + +// GetGlobalDomainUnsigned creates a transaction invoking `getGlobalDomain` 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) GetGlobalDomainUnsigned(name string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "getGlobalDomain", nil, name) +} + func (c *Contract) scriptForRegister(name string, owner util.Uint160, email string, refresh *big.Int, retry *big.Int, expire *big.Int, ttl *big.Int) ([]byte, error) { return smartcontract.CreateCallWithAssertScript(c.hash, "register", name, owner, email, refresh, retry, expire, ttl) } diff --git a/tests/container_test.go b/tests/container_test.go index 035910e..cca95ee 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,64 @@ 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...) + b := c3.TopBlock(t) + + expected := stackitem.NewArray([]stackitem.Item{stackitem.NewBuffer( + []byte(fmt.Sprintf("bober.animals ops@frostfs.info %d %d %d %d %d", + b.Timestamp, defaultRefresh, defaultRetry, defaultExpire, defaultTTL)))}) + cNNS.Invoke(t, expected, "getRecords", "bober.animals", int64(nns.SOA)) + + expected = stackitem.NewArray([]stackitem.Item{ + stackitem.NewBuffer([]byte("bober.animals")), + }) + + cNNS.Invoke(t, false, "isAvailable", "bober.animals") + + cNNS.Invoke(t, expected, "getRecords", "bober.poland.ns", int64(nns.CNAME)) + + 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", "checkAvailableGlobalDomain", "bober.poland.ns") + + c3.Invoke(t, stackitem.Null{}, "delete", cnt.id[:], cnt.sig, cnt.pub, cnt.token) + cNNS.Invoke(t, true, "isAvailable", "bober.animals") + cNNS.Invoke(t, stackitem.Null{}, "checkAvailableGlobalDomain", "bober.poland.ns") + }) + 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..e01a28a 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -99,6 +99,10 @@ func TestNNSRegister(t *testing.T) { "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) + acc := c.NewAccount(t) c2 := c.WithSigners(c.Committee, acc) c2.InvokeFail(t, "not witnessed by admin", "register", @@ -157,6 +161,21 @@ func TestNNSRegister(t *testing.T) { stackitem.NewByteArray([]byte("second TXT record")), }) c.Invoke(t, expected, "getRecords", "testdomain.com", int64(nns.TXT)) + + c3.Invoke(t, stackitem.Null{}, "addRecord", + "testdomain.com", int64(nns.TXT), nns.Cnametgt+"=globaldomain.com") + c.Invoke(t, true, "isAvailable", "dom.testdomain.com") + + c3.Invoke(t, true, "register", + "dom.testdomain.com", acc.ScriptHash(), + "myemail@frostfs.info", refresh, retry, expire, ttl) + + c3.Invoke(t, stackitem.Null{}, "addRecord", + "dom.testdomain.com", int64(nns.TXT), "CID") + + c.InvokeFail(t, "global domain is already taken", "isAvailable", "dom.testdomain.com") + c.Invoke(t, false, "isAvailable", "dom.globaldomain.com") + } func TestTLDRecord(t *testing.T) { @@ -365,6 +384,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 +399,18 @@ 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") + c.Invoke(t, stackitem.Null{}, "checkAvailableGlobalDomain", "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, "global domain is already taken", "checkAvailableGlobalDomain", "dom.dom.domain.com") + c.InvokeFail(t, "domain name too long", "isAvailable", getTooLongDomainName(255)) }