From 11144526396ab2a36c071a31e1701a9df82a2b3a Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Thu, 8 Jun 2023 09:04:58 +0400 Subject: [PATCH 1/6] nns: Change domain name registration access control Previously, NNS contract required signature of the TLD owner to grant access for L2 domain name registration. This didn't allow to properly manage TLDs by committee (only) since it is multi-account and dynamic while all domains were owned by the single account. Also, such behavior made it almost impossible to register L2 domains in practice. To solve the described problems, the following changes are introduced to the access rules (`register` method of the NNS contract): * TLDs are managed only by the committee only; * free L2 domains are free-to-take; * all L3+ domains are managed by parent domain owner or administrator only. Refs #334. Signed-off-by: Leonard Lyubich --- nns/namestate.go | 5 ++++ nns/nns_contract.go | 50 +++++++++++++++++++++++++-------------- tests/nns_test.go | 57 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/nns/namestate.go b/nns/namestate.go index 72bbf944..b9f964ca 100644 --- a/nns/namestate.go +++ b/nns/namestate.go @@ -7,6 +7,7 @@ import ( // NameState represents domain name state. type NameState struct { + // Domain name owner. Nil if owned by the committee. Owner interop.Hash160 Name string Expiration int64 @@ -22,6 +23,10 @@ func (n NameState) ensureNotExpired() { // checkAdmin panics if script container is not signed by the domain name admin. func (n NameState) checkAdmin() { + if len(n.Owner) == 0 { + checkCommittee() + return + } if runtime.CheckWitness(n.Owner) { return } diff --git a/nns/nns_contract.go b/nns/nns_contract.go index 46680e5d..20d7e6c4 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -260,6 +260,12 @@ func parentExpired(ctx storage.Context, first int, fragments []string) bool { } // Register registers a new domain with the specified owner and name if it's available. +// +// Access rules:: +// - TLD can be registered only by the committee +// - 2nd-level domain can be registered by anyone +// - starting from the 3rd level, the domain can only be registered by the +// owner or administrator (if any) of the previous level domain func Register(name string, owner interop.Hash160, email string, refresh, retry, expire, ttl int) bool { fragments := splitAndCheck(name, true) if fragments == nil { @@ -276,27 +282,37 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, panic("TLD already exists") } storage.Put(ctx, tldKey, 0) - } else { - if tldBytes == nil { - panic("TLD not found") - } - if parentExpired(ctx, 1, fragments) { - panic("one of the parent domains is not registered") - } - parentKey := getTokenKey([]byte(name[len(fragments[0])+1:])) + putNameStateWithKey(ctx, getTokenKey([]byte(name)), NameState{ + Name: name, + // NNS expiration is in milliseconds + Expiration: int64(runtime.GetTime() + expire*1000), + }) + putSoaRecord(ctx, name, email, refresh, retry, expire, ttl) + return true + } + + if tldBytes == nil { + panic("TLD not found") + } + if parentExpired(ctx, 1, fragments) { + panic("one of the parent domains is not registered") + } + parentKey := getTokenKey([]byte(name[len(fragments[0])+1:])) + + if l > 2 { nsBytes := storage.Get(ctx, append([]byte{prefixName}, parentKey...)) ns := std.Deserialize(nsBytes.([]byte)).(NameState) ns.checkAdmin() + } - parentRecKey := append([]byte{prefixRecord}, parentKey...) - it := storage.Find(ctx, parentRecKey, storage.ValuesOnly|storage.DeserializeValues) - suffix := []byte(name) - for iterator.Next(it) { - r := iterator.Value(it).(RecordState) - ind := std.MemorySearchLastIndex([]byte(r.Name), suffix, len(r.Name)) - if ind > 0 && ind+len(suffix) == len(r.Name) { - panic("parent domain has conflicting records: " + r.Name) - } + parentRecKey := append([]byte{prefixRecord}, parentKey...) + it := storage.Find(ctx, parentRecKey, storage.ValuesOnly|storage.DeserializeValues) + suffix := []byte(name) + for iterator.Next(it) { + r := iterator.Value(it).(RecordState) + ind := std.MemorySearchLastIndex([]byte(r.Name), suffix, len(r.Name)) + if ind > 0 && ind+len(suffix) == len(r.Name) { + panic("parent domain has conflicting records: " + r.Name) } } diff --git a/tests/nns_test.go b/tests/nns_test.go index 2f8049ca..34f68103 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -11,6 +11,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/interop/storage" "github.com/nspcc-dev/neo-go/pkg/neotest" "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/nspcc-dev/neofs-contract/common" "github.com/nspcc-dev/neofs-contract/nns" "github.com/stretchr/testify/require" ) @@ -78,10 +79,6 @@ func TestNNSRegister(t *testing.T) { "myemail@nspcc.ru", refresh, retry, expire, ttl) acc := c.NewAccount(t) - c2 := c.WithSigners(c.Committee, acc) - c2.InvokeFail(t, "not witnessed by admin", "register", - "testdomain.com", acc.ScriptHash(), - "myemail@nspcc.ru", refresh, retry, expire, ttl) c3 := c.WithSigners(accTop, acc) t.Run("domain names with hyphen", func(t *testing.T) { @@ -371,3 +368,55 @@ func TestNNSResolve(t *testing.T) { c.Invoke(t, records, "resolve", "test.com.", int64(nns.TXT)) c.InvokeFail(t, "invalid domain name format", "resolve", "test.com..", int64(nns.TXT)) } + +func TestNNSRegisterAccess(t *testing.T) { + inv := newNNSInvoker(t, false) + const email, refresh, retry, expire, ttl = "user@domain.org", 0, 1, 2, 3 + const tld = "com" + const registerMethod = "register" + const ownerWitnessFailMsg = "not witnessed by committee" + + // TLD + l2OwnerAcc := inv.NewAccount(t) + l2OwnerInv := inv.WithSigners(l2OwnerAcc) + + l2OwnerInv.InvokeFail(t, ownerWitnessFailMsg, registerMethod, + tld, l2OwnerAcc.ScriptHash(), email, refresh, retry, expire, ttl) + l2OwnerInv.InvokeFail(t, ownerWitnessFailMsg, registerMethod, + tld, nil, email, refresh, retry, expire, ttl) + + inv.WithSigners(inv.Committee).Invoke(t, true, registerMethod, + tld, nil, email, refresh, retry, expire, ttl) + + // L2 + const l2 = "l2." + tld + + anonymousAcc := inv.NewAccount(t) + anonymousInv := inv.WithSigners(anonymousAcc) + + l2OwnerInv.InvokeFail(t, "invalid owner", registerMethod, + l2, nil, email, refresh, retry, expire, ttl) + l2OwnerInv.InvokeFail(t, common.ErrOwnerWitnessFailed, registerMethod, + l2, anonymousAcc.ScriptHash(), email, refresh, retry, expire, ttl) + l2OwnerInv.Invoke(t, true, registerMethod, + l2, l2OwnerAcc.ScriptHash(), email, refresh, retry, expire, ttl) + + // L3 (by L2 owner) + const l3ByL2Owner = "l3-owner." + l2 + + l2OwnerInv.Invoke(t, true, registerMethod, + l3ByL2Owner, l2OwnerAcc.ScriptHash(), email, refresh, retry, expire, ttl) + + // L3 (by L2 admin) + const l3ByL2Admin = "l3-admin." + l2 + + l2AdminAcc := inv.NewAccount(t) + l2AdminInv := inv.WithSigners(l2AdminAcc) + + inv.WithSigners(l2OwnerAcc, l2AdminAcc).Invoke(t, stackitem.Null{}, "setAdmin", l2, l2AdminAcc.ScriptHash()) + + anonymousInv.InvokeFail(t, "not witnessed by admin", registerMethod, + l3ByL2Admin, anonymousAcc.ScriptHash(), email, refresh, retry, expire, ttl) + l2AdminInv.Invoke(t, true, "register", + l3ByL2Admin, l2AdminAcc.ScriptHash(), email, refresh, retry, expire, ttl) +} From 1d68511ed3f670d027db42344eb248c4ade226b0 Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Thu, 8 Jun 2023 09:19:07 +0400 Subject: [PATCH 2/6] nns: Allow to register pre-defined TLDs on deployment stage In order to register TLD, committee multi-signature must be gathered. It is not always easy. For example, on "fresh" network, committee members may not have a notary role, so they will not be able to use Notary service to collect a signature. At the same time, Notary service is the only convenient way to collect a multi-signature in a distributed mode. To simplify the solution of the described task, it would be convenient to be able to register pre-known TLDs at the NNS contract deployment stage (which is performed by the committee). Support list of (name, e-mail) pairs describing pre-defined TLDs as optional deployment parameters (passed into `_deploy` callback). Also make `migration.NewContract` to pre-register 'neofs' TLD. Refs #334. Signed-off-by: Leonard Lyubich --- nns/nns_contract.go | 67 ++++++++++++++++++++++++++++++-------- tests/migration/storage.go | 14 ++------ tests/nns_test.go | 45 +++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 27 deletions(-) diff --git a/nns/nns_contract.go b/nns/nns_contract.go index 20d7e6c4..f0a23f5f 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -100,6 +100,20 @@ func _deploy(data interface{}, isUpdate bool) { ctx := storage.GetContext() storage.Put(ctx, []byte{prefixTotalSupply}, 0) storage.Put(ctx, []byte{prefixRegisterPrice}, defaultRegisterPrice) + + if data != nil { // for backward compatibility + args := data.(struct { + tldSet []struct { + name string + email string + } + }) + + for i := range args.tldSet { + saveCommitteeDomain(ctx, args.tldSet[i].name, args.tldSet[i].email) + runtime.Log("registered committee domain " + args.tldSet[i].name) + } + } } // Symbol returns NeoNameService symbol. @@ -273,7 +287,7 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, } l := len(fragments) - tldKey := append([]byte{prefixRoot}, []byte(fragments[l-1])...) + tldKey := makeTLDKey(fragments[l-1]) ctx := storage.GetContext() tldBytes := storage.Get(ctx, tldKey) if l == 1 { @@ -281,13 +295,8 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, if tldBytes != nil { panic("TLD already exists") } - storage.Put(ctx, tldKey, 0) - putNameStateWithKey(ctx, getTokenKey([]byte(name)), NameState{ - Name: name, - // NNS expiration is in milliseconds - Expiration: int64(runtime.GetTime() + expire*1000), - }) - putSoaRecord(ctx, name, email, refresh, retry, expire, ttl) + putTLD(ctx, tldKey) + saveDomain(ctx, name, email, refresh, retry, expire, ttl, nil) return true } @@ -336,17 +345,39 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, } else { updateTotalSupply(ctx, +1) } - ns := NameState{ + saveDomain(ctx, name, email, refresh, retry, expire, ttl, owner) + updateBalance(ctx, []byte(name), owner, +1) + postTransfer(oldOwner, owner, []byte(name), nil) + return true +} + +func saveCommitteeDomain(ctx storage.Context, name, email string) { + putTLD(ctx, makeTLDKey(name)) + + // TODO: values from NeoFS ADM, check + const ( + refresh = 3600 + retry = 600 + expire = 10 * 365 * 24 * 60 * 60 // 10 years + ttl = 3600 + ) + var committeeOwner interop.Hash160 + saveDomain(ctx, name, email, refresh, retry, expire, ttl, committeeOwner) +} + +// saveDomain constructs NameState and RecordState of SOA type for the domain +// based on parameters and saves these descriptors in the contract storage. +// Empty owner parameter corresponds to owner-by-committee domains. +// +// Provided storage.Context MUST be read-write. +func saveDomain(ctx storage.Context, name, email string, refresh, retry, expire, ttl int, owner interop.Hash160) { + putNameStateWithKey(ctx, getTokenKey([]byte(name)), NameState{ Owner: owner, Name: name, // NNS expiration is in milliseconds Expiration: int64(runtime.GetTime() + expire*1000), - } - putNameStateWithKey(ctx, tokenKey, ns) + }) putSoaRecord(ctx, name, email, refresh, retry, expire, ttl) - updateBalance(ctx, []byte(name), owner, +1) - postTransfer(oldOwner, owner, []byte(name), nil) - return true } // Renew increases domain expiration date. @@ -927,3 +958,11 @@ func getAllRecords(ctx storage.Context, name string) iterator.Iterator { recordsKey := getRecordsKey(tokenID, name) return storage.Find(ctx, recordsKey, storage.ValuesOnly|storage.DeserializeValues) } + +func putTLD(ctx storage.Context, key []byte) { + storage.Put(ctx, key, 0) +} + +func makeTLDKey(name string) []byte { + return append([]byte{prefixRoot}, name...) +} diff --git a/tests/migration/storage.go b/tests/migration/storage.go index 97b9a457..0d663344 100644 --- a/tests/migration/storage.go +++ b/tests/migration/storage.go @@ -152,7 +152,9 @@ func NewContract(tb testing.TB, d *dump.Reader, name string, opts ContractOption const nnsSourceCodeDir = "../nns" exec.DeployContract(tb, neotest.CompileFile(tb, exec.CommitteeHash, nnsSourceCodeDir, filepath.Join(nnsSourceCodeDir, "config.yml")), - []interface{}{}, + []interface{}{ + []interface{}{[]interface{}{"neofs", "ops@morphbits.io"}}, + }, ) // compile new contract version @@ -244,16 +246,6 @@ func (x *Contract) GetStorageItem(key []byte) []byte { // See also nns.Register, nns.AddRecord. func (x *Contract) RegisterContractInNNS(tb testing.TB, name string, addr util.Uint160) { nnsInvoker := x.exec.CommitteeInvoker(x.exec.ContractHash(tb, 1)) - nnsInvoker.InvokeAndCheck(tb, checkSingleTrueInStack, "register", - "neofs", - x.exec.CommitteeHash, - "ops@morphbits.io", - int64(3600), - int64(600), - int64(10*365*24*time.Hour/time.Second), - int64(3600), - ) - domain := name + ".neofs" nnsInvoker.InvokeAndCheck(tb, checkSingleTrueInStack, "register", diff --git a/tests/nns_test.go b/tests/nns_test.go index 34f68103..69b775c1 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -10,6 +10,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/interop/storage" "github.com/nspcc-dev/neo-go/pkg/neotest" + "github.com/nspcc-dev/neo-go/pkg/util" "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" "github.com/nspcc-dev/neofs-contract/common" "github.com/nspcc-dev/neofs-contract/nns" @@ -20,10 +21,18 @@ const nnsPath = "../nns" const msPerYear = 365 * 24 * time.Hour / time.Millisecond -func newNNSInvoker(t *testing.T, addRoot bool) *neotest.ContractInvoker { +func newNNSInvoker(t *testing.T, addRoot bool, tldSet ...string) *neotest.ContractInvoker { e := newExecutor(t) ctr := neotest.CompileFile(t, e.CommitteeHash, nnsPath, path.Join(nnsPath, "config.yml")) - e.DeployContract(t, ctr, nil) + if len(tldSet) > 0 { + _tldSet := make([]interface{}, len(tldSet)) + for i := range tldSet { + _tldSet[i] = []interface{}{tldSet[i], "user@domain.org"} + } + e.DeployContract(t, ctr, []interface{}{_tldSet}) + } else { + e.DeployContract(t, ctr, nil) + } c := e.CommitteeInvoker(ctr.Hash) if addRoot { @@ -420,3 +429,35 @@ func TestNNSRegisterAccess(t *testing.T) { l2AdminInv.Invoke(t, true, "register", l3ByL2Admin, l2AdminAcc.ScriptHash(), email, refresh, retry, expire, ttl) } + +func TestPredefinedTLD(t *testing.T) { + const anyTLD1 = "hello" + const anyTLD2 = "world" + + inv := newNNSInvoker(t, false, anyTLD1, anyTLD2) + + require.Nil(t, getDomainOwner(t, inv, anyTLD1)) + require.Nil(t, getDomainOwner(t, inv, anyTLD2)) +} + +// getDomainOwner reads owner of the domain. Returns nil if domain is owned by the committee. +func getDomainOwner(tb testing.TB, inv *neotest.ContractInvoker, domain string) *util.Uint160 { + stack, err := inv.TestInvoke(tb, "ownerOf", domain) + require.NoError(tb, err) + + arr := stack.ToArray() + require.Len(tb, arr, 1) + + item := arr[0] + if _, ok := item.(stackitem.Null); ok { + return nil + } + + b, err := item.TryBytes() + require.NoError(tb, err) + + res, err := util.Uint160DecodeBytesBE(b) + require.NoError(tb, err) + + return &res +} From 5758c841f24b3a0bd9c10f6aa4840f1ec3cf580e Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Sat, 10 Jun 2023 10:26:45 +0400 Subject: [PATCH 3/6] nns: Prevent users from accessing top-level domains Top-level domains aren't NFTs, therefore NNS contract must not treat them as such. At the same time, technically TLDs are valid domains (e.g. 'org'), so nothing will prevent the user from performing operations with them. Based on this, the best approach would be treating TLDs as non-existent domains. Throw 'token not found' exception on TLD input of any method. The storage model is left the same: this allows us not to perform migration and implement the behavior logically. Refs 334. Signed-off-by: Leonard Lyubich --- nns/nns_contract.go | 84 ++++++++++++++++++++++++++++++++++++++++++--- tests/nns_test.go | 60 +++++++++++++++----------------- 2 files changed, 106 insertions(+), 38 deletions(-) diff --git a/nns/nns_contract.go b/nns/nns_contract.go index f0a23f5f..8257484f 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -137,15 +137,29 @@ func TotalSupply() int { return getTotalSupply(ctx) } -// OwnerOf returns the owner of the specified domain. +// OwnerOf returns the owner of the specified domain. The tokenID domain MUST +// NOT be a TLD. func OwnerOf(tokenID []byte) interop.Hash160 { + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(string(tokenID), ".") + if len(fragments) == 1 { + panic("token not found") + } + ctx := storage.GetReadOnlyContext() ns := getNameState(ctx, tokenID) return ns.Owner } // Properties returns a domain name and an expiration date of the specified domain. +// The tokenID MUST NOT be a TLD. func Properties(tokenID []byte) map[string]interface{} { + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(string(tokenID), ".") + if len(fragments) == 1 { + panic("token not found") + } + ctx := storage.GetReadOnlyContext() ns := getNameState(ctx, tokenID) return map[string]interface{}{ @@ -183,10 +197,18 @@ func TokensOf(owner interop.Hash160) iterator.Iterator { } // Transfer transfers the domain with the specified name to a new owner. +// The tokenID MUST NOT be a TLD. func Transfer(to interop.Hash160, tokenID []byte, data interface{}) bool { if !isValid(to) { panic(`invalid receiver`) } + + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(string(tokenID), ".") + if len(fragments) == 1 { + panic("token not found") + } + var ( tokenKey = getTokenKey(tokenID) ctx = storage.GetContext() @@ -234,7 +256,8 @@ func GetPrice() int { return storage.Get(ctx, []byte{prefixRegisterPrice}).(int) } -// IsAvailable checks whether the provided domain name is available. +// IsAvailable checks whether the provided domain name is available. Notice that +// TLD is available for the committee only. func IsAvailable(name string) bool { fragments := splitAndCheck(name, false) if fragments == nil { @@ -405,11 +428,18 @@ func UpdateSOA(name, email string, refresh, retry, expire, ttl int) { putSoaRecord(ctx, name, email, refresh, retry, expire, ttl) } -// SetAdmin updates domain admin. +// SetAdmin updates domain admin. The name MUST NOT be a TLD. func SetAdmin(name string, admin interop.Hash160) { if len(name) > maxDomainNameLength { panic("invalid domain name format") } + + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(name, ".") + if len(fragments) == 1 { + panic("token not found") + } + if admin != nil && !runtime.CheckWitness(admin) { panic("not witnessed by admin") } @@ -421,11 +451,19 @@ func SetAdmin(name string, admin interop.Hash160) { } // SetRecord adds a new record of the specified type to the provided domain. +// The name MUST NOT be a TLD. func SetRecord(name string, typ RecordType, id byte, data string) { tokenID := []byte(tokenIDFromName(name)) if !checkBaseRecords(typ, data) { panic("invalid record data") } + + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(name, ".") + if len(fragments) == 1 { + panic("token not found") + } + ctx := storage.GetContext() ns := getNameState(ctx, tokenID) ns.checkAdmin() @@ -449,11 +487,19 @@ func checkBaseRecords(typ RecordType, data string) bool { } // AddRecord adds a new record of the specified type to the provided domain. +// The name MUST NOT be a TLD. func AddRecord(name string, typ RecordType, data string) { tokenID := []byte(tokenIDFromName(name)) if !checkBaseRecords(typ, data) { panic("invalid record data") } + + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(string(tokenID), ".") + if len(fragments) == 1 { + panic("token not found") + } + ctx := storage.GetContext() ns := getNameState(ctx, tokenID) ns.checkAdmin() @@ -462,19 +508,33 @@ func AddRecord(name string, typ RecordType, data string) { } // GetRecords returns domain record of the specified type if it exists or an empty -// string if not. +// string if not. The name MUST NOT be a TLD. func GetRecords(name string, typ RecordType) []string { + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(name, ".") + if len(fragments) == 1 { + panic("token not found") + } + tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetReadOnlyContext() _ = getNameState(ctx, tokenID) // ensure not expired return getRecordsByType(ctx, tokenID, name, typ) } -// DeleteRecords removes domain records with the specified type. +// DeleteRecords removes domain records with the specified type. The name MUST +// NOT be a TLD. func DeleteRecords(name string, typ RecordType) { if typ == SOA { panic("you cannot delete soa record") } + + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(name, ".") + if len(fragments) == 1 { + panic("token not found") + } + tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetContext() ns := getNameState(ctx, tokenID) @@ -489,13 +549,27 @@ func DeleteRecords(name string, typ RecordType) { } // Resolve resolves given name (not more then three redirects are allowed). +// The name MUST NOT be a TLD. func Resolve(name string, typ RecordType) []string { + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(name, ".") + if len(fragments) == 1 { + panic("token not found") + } + ctx := storage.GetReadOnlyContext() return resolve(ctx, nil, name, typ, 2) } // GetAllRecords returns an Iterator with RecordState items for the given name. +// The name MUST NOT be a TLD. func GetAllRecords(name string) iterator.Iterator { + // TODO: same done in getNameState, don't do twice + fragments := std.StringSplit(name, ".") + if len(fragments) == 1 { + panic("token not found") + } + tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetReadOnlyContext() _ = getNameState(ctx, tokenID) // ensure not expired diff --git a/tests/nns_test.go b/tests/nns_test.go index 69b775c1..851a67e7 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -133,15 +133,6 @@ func TestNNSRegister(t *testing.T) { c.Invoke(t, expected, "getRecords", "testdomain.com", int64(nns.TXT)) } -func TestTLDRecord(t *testing.T) { - c := newNNSInvoker(t, true) - c.Invoke(t, stackitem.Null{}, "addRecord", - "com", int64(nns.A), "1.2.3.4") - - result := []stackitem.Item{stackitem.NewByteArray([]byte("1.2.3.4"))} - c.Invoke(t, result, "resolve", "com", int64(nns.A)) -} - func TestNNSRegisterMulti(t *testing.T) { c := newNNSInvoker(t, true) @@ -431,33 +422,36 @@ func TestNNSRegisterAccess(t *testing.T) { } func TestPredefinedTLD(t *testing.T) { - const anyTLD1 = "hello" - const anyTLD2 = "world" + predefined := []string{"hello", "world"} + const otherTLD = "goodbye" - inv := newNNSInvoker(t, false, anyTLD1, anyTLD2) + inv := newNNSInvoker(t, false, predefined...) - require.Nil(t, getDomainOwner(t, inv, anyTLD1)) - require.Nil(t, getDomainOwner(t, inv, anyTLD2)) -} + inv.Invoke(t, true, "isAvailable", otherTLD) -// getDomainOwner reads owner of the domain. Returns nil if domain is owned by the committee. -func getDomainOwner(tb testing.TB, inv *neotest.ContractInvoker, domain string) *util.Uint160 { - stack, err := inv.TestInvoke(tb, "ownerOf", domain) - require.NoError(tb, err) - - arr := stack.ToArray() - require.Len(tb, arr, 1) - - item := arr[0] - if _, ok := item.(stackitem.Null); ok { - return nil + for i := range predefined { + inv.Invoke(t, false, "isAvailable", predefined[i]) } +} - b, err := item.TryBytes() - require.NoError(tb, err) - - res, err := util.Uint160DecodeBytesBE(b) - require.NoError(tb, err) - - return &res +func TestNNSTLD(t *testing.T) { + const tld = "any-tld" + const tldFailMsg = "token not found" + const recTyp = int64(nns.TXT) // InvokeFail doesn't support nns.RecordType + + inv := newNNSInvoker(t, false, tld) + + inv.InvokeFail(t, tldFailMsg, "addRecord", tld, recTyp, "any data") + inv.InvokeFail(t, tldFailMsg, "deleteRecords", tld, recTyp) + inv.InvokeFail(t, tldFailMsg, "getAllRecords", tld) + inv.InvokeFail(t, tldFailMsg, "getRecords", tld, recTyp) + inv.Invoke(t, false, "isAvailable", tld) + inv.InvokeFail(t, tldFailMsg, "ownerOf", tld) + inv.InvokeFail(t, tldFailMsg, "properties", tld) + inv.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) {}, "renew", tld) + inv.InvokeFail(t, tldFailMsg, "resolve", tld, recTyp) + inv.InvokeFail(t, tldFailMsg, "setAdmin", tld, util.Uint160{}) + inv.InvokeFail(t, tldFailMsg, "setRecord", tld, recTyp, 1, "any data") + inv.InvokeFail(t, tldFailMsg, "transfer", util.Uint160{}, tld, nil) + inv.Invoke(t, stackitem.Null{}, "updateSOA", tld, "user@domain.org", 0, 1, 2, 3) } From 901d3d93cac7908efa322609c1a74e1a85624b3a Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Sat, 10 Jun 2023 11:15:36 +0400 Subject: [PATCH 4/6] nns: Add dedicated method for TLD registration Top-level domains are controlled by the committee. Previously, NNS contract provided `register` method that accepted fixed domain owner. After recent changes, TLDs were forbidden to be treated as regular tokens in terms of the NNS contract. According to this, `register` method is no longer well-suited for TLD registration, so, it's worth to provide dedicated method for this purpose. Add `registerTLD` method with signature similar to the `register` one but w/o owner parameter. Closes #334. Signed-off-by: Leonard Lyubich --- container/config.yml | 2 +- container/container_contract.go | 9 ++--- nns/nns_contract.go | 65 +++++++++++++++++++-------------- tests/container_test.go | 5 +-- tests/nns_test.go | 46 +++++++++++------------ 5 files changed, 65 insertions(+), 62 deletions(-) diff --git a/container/config.yml b/container/config.yml index 026efa88..7e52be76 100644 --- a/container/config.yml +++ b/container/config.yml @@ -2,7 +2,7 @@ name: "NeoFS Container" safemethods: ["count", "containersOf", "get", "owner", "list", "eACL", "getContainerSize", "listContainerSizes", "iterateContainerSizes", "iterateAllContainerSizes", "version"] permissions: - methods: ["update", "addKey", "transferX", - "register", "addRecord", "deleteRecords"] + "register", "registerTLD", "addRecord", "deleteRecords"] events: - name: PutSuccess parameters: diff --git a/container/container_contract.go b/container/container_contract.go index 95ec613d..7da1e2f5 100644 --- a/container/container_contract.go +++ b/container/container_contract.go @@ -208,12 +208,9 @@ func registerNiceNameTLD(addrNNS interop.Hash160, nnsRoot string) { return } - res := contract.Call(addrNNS, "register", contract.All, - nnsRoot, runtime.GetExecutingScriptHash(), "ops@nspcc.ru", - defaultRefresh, defaultRetry, defaultExpire, defaultTTL).(bool) - if !res { - panic("can't register NNS TLD") - } + contract.Call(addrNNS, "registerTLD", contract.All, + nnsRoot, "ops@nspcc.ru", + defaultRefresh, defaultRetry, defaultExpire, defaultTTL) } // Update method updates contract source code and manifest. It can be invoked diff --git a/nns/nns_contract.go b/nns/nns_contract.go index 8257484f..87071d29 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -110,7 +110,13 @@ func _deploy(data interface{}, isUpdate bool) { }) for i := range args.tldSet { - saveCommitteeDomain(ctx, args.tldSet[i].name, args.tldSet[i].email) + const ( + refresh = 3600 + retry = 600 + expire = 10 * 365 * 24 * 60 * 60 // 10 years + ttl = 3600 + ) + saveCommitteeDomain(ctx, args.tldSet[i].name, args.tldSet[i].email, refresh, retry, expire, ttl) runtime.Log("registered committee domain " + args.tldSet[i].name) } } @@ -296,10 +302,11 @@ func parentExpired(ctx storage.Context, first int, fragments []string) bool { return false } -// Register registers a new domain with the specified owner and name if it's available. +// Register registers a new domain with the specified owner and name if it's +// available. Top-level domains MUST NOT be registered via Register, use +// RegisterTLD for this. // // Access rules:: -// - TLD can be registered only by the committee // - 2nd-level domain can be registered by anyone // - starting from the 3rd level, the domain can only be registered by the // owner or administrator (if any) of the previous level domain @@ -310,20 +317,13 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, } l := len(fragments) - tldKey := makeTLDKey(fragments[l-1]) - ctx := storage.GetContext() - tldBytes := storage.Get(ctx, tldKey) if l == 1 { - checkCommittee() - if tldBytes != nil { - panic("TLD already exists") - } - putTLD(ctx, tldKey) - saveDomain(ctx, name, email, refresh, retry, expire, ttl, nil) - return true + panic("TLD denied") } - if tldBytes == nil { + ctx := storage.GetContext() + + if storage.Get(ctx, makeTLDKey(fragments[l-1])) == nil { panic("TLD not found") } if parentExpired(ctx, 1, fragments) { @@ -374,16 +374,31 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, return true } -func saveCommitteeDomain(ctx storage.Context, name, email string) { - putTLD(ctx, makeTLDKey(name)) +// RegisterTLD registers new top-level domain. RegisterTLD MUST be called by the +// committee only. Name MUST be a valid TLD. +// +// RegisterTLD panics with 'TLD already exists' if domain already exists. +func RegisterTLD(name, email string, refresh, retry, expire, ttl int) { + checkCommittee() + saveCommitteeDomain(storage.GetContext(), name, email, refresh, retry, expire, ttl) +} + +// saveCommitteeDomain marks TLD as registered via prefixRoot storage +// record and saves domain state calling saveDomain with given parameters and +// empty owner. The name MUST be a valid TLD name. +func saveCommitteeDomain(ctx storage.Context, name, email string, refresh, retry, expire, ttl int) { + fragments := splitAndCheck(name, false) + if len(fragments) != 1 { + panic("invalid domain name format") + } + + tldKey := makeTLDKey(name) + if storage.Get(ctx, tldKey) != nil { + panic("TLD already exists") + } + + storage.Put(ctx, tldKey, 0) - // TODO: values from NeoFS ADM, check - const ( - refresh = 3600 - retry = 600 - expire = 10 * 365 * 24 * 60 * 60 // 10 years - ttl = 3600 - ) var committeeOwner interop.Hash160 saveDomain(ctx, name, email, refresh, retry, expire, ttl, committeeOwner) } @@ -1033,10 +1048,6 @@ func getAllRecords(ctx storage.Context, name string) iterator.Iterator { return storage.Find(ctx, recordsKey, storage.ValuesOnly|storage.DeserializeValues) } -func putTLD(ctx storage.Context, key []byte) { - storage.Put(ctx, key, 0) -} - func makeTLDKey(name string) []byte { return append([]byte{prefixRoot}, name...) } diff --git a/tests/container_test.go b/tests/container_test.go index 8e6cd8ba..6bf3d21c 100644 --- a/tests/container_test.go +++ b/tests/container_test.go @@ -208,9 +208,8 @@ func TestContainerPut(t *testing.T) { cnt.value[len(cnt.value)-1] = 10 cnt.id = sha256.Sum256(cnt.value) - cNNS.Invoke(t, true, "register", - "cdn", c.CommitteeHash, - "whateveriwant@world.com", int64(0), int64(0), int64(100_000), int64(0)) + cNNS.Invoke(t, stackitem.Null{}, "registerTLD", + "cdn", "whateveriwant@world.com", int64(0), int64(0), int64(100_000), int64(0)) cNNS.Invoke(t, true, "register", "domain.cdn", c.CommitteeHash, diff --git a/tests/nns_test.go b/tests/nns_test.go index 851a67e7..13fa16a5 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -38,9 +38,8 @@ func newNNSInvoker(t *testing.T, addRoot bool, tldSet ...string) *neotest.Contra if addRoot { // Set expiration big enough to pass all tests. refresh, retry, expire, ttl := int64(101), int64(102), int64(msPerYear/1000*100), int64(104) - c.Invoke(t, true, "register", - "com", c.CommitteeHash, - "myemail@nspcc.ru", refresh, retry, expire, ttl) + c.Invoke(t, stackitem.Null{}, "registerTLD", + "com", "myemail@nspcc.ru", refresh, retry, expire, ttl) } return c } @@ -58,23 +57,19 @@ func TestNNSRegisterTLD(t *testing.T) { refresh, retry, expire, ttl := int64(101), int64(102), int64(103), int64(104) - c.InvokeFail(t, "invalid domain name format", "register", - "0com", c.CommitteeHash, - "email@nspcc.ru", refresh, retry, expire, ttl) + c.InvokeFail(t, "invalid domain name format", "registerTLD", + "0com", "email@nspcc.ru", refresh, retry, expire, ttl) acc := c.NewAccount(t) cAcc := c.WithSigners(acc) - cAcc.InvokeFail(t, "not witnessed by committee", "register", - "com", acc.ScriptHash(), - "email@nspcc.ru", refresh, retry, expire, ttl) + cAcc.InvokeFail(t, "not witnessed by committee", "registerTLD", + "com", "email@nspcc.ru", refresh, retry, expire, ttl) - c.Invoke(t, true, "register", - "com", c.CommitteeHash, - "email@nspcc.ru", refresh, retry, expire, ttl) + c.Invoke(t, stackitem.Null{}, "registerTLD", + "com", "email@nspcc.ru", refresh, retry, expire, ttl) - c.InvokeFail(t, "TLD already exists", "register", - "com", c.CommitteeHash, - "email@nspcc.ru", refresh, retry, expire, ttl) + c.InvokeFail(t, "TLD already exists", "registerTLD", + "com", "email@nspcc.ru", refresh, retry, expire, ttl) } func TestNNSRegister(t *testing.T) { @@ -83,9 +78,8 @@ func TestNNSRegister(t *testing.T) { 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@nspcc.ru", refresh, retry, expire, ttl) + c1.Invoke(t, stackitem.Null{}, "registerTLD", + "com", "myemail@nspcc.ru", refresh, retry, expire, ttl) acc := c.NewAccount(t) @@ -313,9 +307,8 @@ func TestNNSIsAvailable(t *testing.T) { c.InvokeFail(t, "TLD not found", "isAvailable", "domain.com") refresh, retry, expire, ttl := int64(101), int64(102), int64(103), int64(104) - c.Invoke(t, true, "register", - "com", c.CommitteeHash, - "myemail@nspcc.ru", refresh, retry, expire, ttl) + c.Invoke(t, stackitem.Null{}, "registerTLD", + "com", "myemail@nspcc.ru", refresh, retry, expire, ttl) c.Invoke(t, false, "isAvailable", "com") c.Invoke(t, true, "isAvailable", "domain.com") @@ -374,19 +367,22 @@ func TestNNSRegisterAccess(t *testing.T) { const email, refresh, retry, expire, ttl = "user@domain.org", 0, 1, 2, 3 const tld = "com" const registerMethod = "register" - const ownerWitnessFailMsg = "not witnessed by committee" + const registerTLDMethod = "registerTLD" + const tldDeniedFailMsg = "TLD denied" // TLD l2OwnerAcc := inv.NewAccount(t) l2OwnerInv := inv.WithSigners(l2OwnerAcc) - l2OwnerInv.InvokeFail(t, ownerWitnessFailMsg, registerMethod, + l2OwnerInv.InvokeFail(t, tldDeniedFailMsg, registerMethod, tld, l2OwnerAcc.ScriptHash(), email, refresh, retry, expire, ttl) - l2OwnerInv.InvokeFail(t, ownerWitnessFailMsg, registerMethod, + l2OwnerInv.InvokeFail(t, tldDeniedFailMsg, registerMethod, tld, nil, email, refresh, retry, expire, ttl) - inv.WithSigners(inv.Committee).Invoke(t, true, registerMethod, + inv.WithSigners(inv.Committee).InvokeFail(t, tldDeniedFailMsg, registerMethod, tld, nil, email, refresh, retry, expire, ttl) + inv.WithSigners(inv.Committee).Invoke(t, stackitem.Null{}, registerTLDMethod, + tld, email, refresh, retry, expire, ttl) // L2 const l2 = "l2." + tld From ebbcbaf678fc215583b21a50058666f96233c3ff Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Wed, 14 Jun 2023 14:25:43 +0400 Subject: [PATCH 5/6] tests: Add test for `roots` method of the NNS contract Signed-off-by: Leonard Lyubich --- tests/nns_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/nns_test.go b/tests/nns_test.go index 13fa16a5..27267a3f 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -451,3 +451,29 @@ func TestNNSTLD(t *testing.T) { inv.InvokeFail(t, tldFailMsg, "transfer", util.Uint160{}, tld, nil) inv.Invoke(t, stackitem.Null{}, "updateSOA", tld, "user@domain.org", 0, 1, 2, 3) } + +func TestNNSRoots(t *testing.T) { + tlds := []string{"hello", "world"} + + inv := newNNSInvoker(t, false, tlds...) + + stack, err := inv.TestInvoke(t, "roots") + require.NoError(t, err) + require.NotEmpty(t, stack) + + it, ok := stack.Pop().Value().(*storage.Iterator) + require.True(t, ok) + + var res []string + + for it.Next() { + item := it.Value() + + b, err := item.TryBytes() + require.NoError(t, err) + + res = append(res, string(b)) + } + + require.ElementsMatch(t, tlds, res) +} From ecf827068208e5cda82d3972d1a0abed70981d2e Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Fri, 16 Jun 2023 14:10:56 +0400 Subject: [PATCH 6/6] nns: Avoid double domain name fragmentation within one method After recent changes, some contract methods pre-calculate fragments of the requested domain name to process TLDs. In most cases, these methods call `getNameState` function in subsequent instructions which also performs fragmentation. In order to avoid resource costs for a duplicated action, an already calculated partition should be reused. Add `getFragmentedNameState` function which allows to pass pre-calculated fragments. The function is implemented with the possibility of a direct call from `getNameState` while maintaining the behavior of the latter. Signed-off-by: Leonard Lyubich --- nns/nns_contract.go | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/nns/nns_contract.go b/nns/nns_contract.go index 87071d29..6554f8e2 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -146,28 +146,26 @@ func TotalSupply() int { // OwnerOf returns the owner of the specified domain. The tokenID domain MUST // NOT be a TLD. func OwnerOf(tokenID []byte) interop.Hash160 { - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(string(tokenID), ".") if len(fragments) == 1 { panic("token not found") } ctx := storage.GetReadOnlyContext() - ns := getNameState(ctx, tokenID) + ns := getFragmentedNameState(ctx, tokenID, fragments) return ns.Owner } // Properties returns a domain name and an expiration date of the specified domain. // The tokenID MUST NOT be a TLD. func Properties(tokenID []byte) map[string]interface{} { - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(string(tokenID), ".") if len(fragments) == 1 { panic("token not found") } ctx := storage.GetReadOnlyContext() - ns := getNameState(ctx, tokenID) + ns := getFragmentedNameState(ctx, tokenID, fragments) return map[string]interface{}{ "name": ns.Name, "expiration": ns.Expiration, @@ -209,7 +207,6 @@ func Transfer(to interop.Hash160, tokenID []byte, data interface{}) bool { panic(`invalid receiver`) } - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(string(tokenID), ".") if len(fragments) == 1 { panic("token not found") @@ -449,7 +446,6 @@ func SetAdmin(name string, admin interop.Hash160) { panic("invalid domain name format") } - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(name, ".") if len(fragments) == 1 { panic("token not found") @@ -459,7 +455,7 @@ func SetAdmin(name string, admin interop.Hash160) { panic("not witnessed by admin") } ctx := storage.GetContext() - ns := getNameState(ctx, []byte(name)) + ns := getFragmentedNameState(ctx, []byte(name), fragments) common.CheckOwnerWitness(ns.Owner) ns.Admin = admin putNameState(ctx, ns) @@ -473,14 +469,13 @@ func SetRecord(name string, typ RecordType, id byte, data string) { panic("invalid record data") } - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(name, ".") if len(fragments) == 1 { panic("token not found") } ctx := storage.GetContext() - ns := getNameState(ctx, tokenID) + ns := getFragmentedNameState(ctx, tokenID, fragments) ns.checkAdmin() putRecord(ctx, tokenID, name, typ, id, data) updateSoaSerial(ctx, tokenID) @@ -509,14 +504,13 @@ func AddRecord(name string, typ RecordType, data string) { panic("invalid record data") } - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(string(tokenID), ".") if len(fragments) == 1 { panic("token not found") } ctx := storage.GetContext() - ns := getNameState(ctx, tokenID) + ns := getFragmentedNameState(ctx, tokenID, fragments) ns.checkAdmin() addRecord(ctx, tokenID, name, typ, data) updateSoaSerial(ctx, tokenID) @@ -525,7 +519,6 @@ func AddRecord(name string, typ RecordType, data string) { // GetRecords returns domain record of the specified type if it exists or an empty // string if not. The name MUST NOT be a TLD. func GetRecords(name string, typ RecordType) []string { - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(name, ".") if len(fragments) == 1 { panic("token not found") @@ -533,7 +526,7 @@ func GetRecords(name string, typ RecordType) []string { tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetReadOnlyContext() - _ = getNameState(ctx, tokenID) // ensure not expired + _ = getFragmentedNameState(ctx, tokenID, fragments) // ensure not expired return getRecordsByType(ctx, tokenID, name, typ) } @@ -544,15 +537,15 @@ func DeleteRecords(name string, typ RecordType) { panic("you cannot delete soa record") } - // TODO: same done in getNameState, don't do twice - fragments := std.StringSplit(name, ".") + tokenID := []byte(tokenIDFromName(name)) + + fragments := std.StringSplit(string(tokenID), ".") if len(fragments) == 1 { panic("token not found") } - tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetContext() - ns := getNameState(ctx, tokenID) + ns := getFragmentedNameState(ctx, tokenID, fragments) ns.checkAdmin() recordsKey := getRecordsKeyByType(tokenID, name, typ) records := storage.Find(ctx, recordsKey, storage.KeysOnly) @@ -566,7 +559,6 @@ func DeleteRecords(name string, typ RecordType) { // Resolve resolves given name (not more then three redirects are allowed). // The name MUST NOT be a TLD. func Resolve(name string, typ RecordType) []string { - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(name, ".") if len(fragments) == 1 { panic("token not found") @@ -579,7 +571,6 @@ func Resolve(name string, typ RecordType) []string { // GetAllRecords returns an Iterator with RecordState items for the given name. // The name MUST NOT be a TLD. func GetAllRecords(name string) iterator.Iterator { - // TODO: same done in getNameState, don't do twice fragments := std.StringSplit(name, ".") if len(fragments) == 1 { panic("token not found") @@ -587,7 +578,7 @@ func GetAllRecords(name string) iterator.Iterator { tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetReadOnlyContext() - _ = getNameState(ctx, tokenID) // ensure not expired + _ = getFragmentedNameState(ctx, tokenID, fragments) // ensure not expired recordsKey := getRecordsKey(tokenID, name) return storage.Find(ctx, recordsKey, storage.ValuesOnly|storage.DeserializeValues) } @@ -644,9 +635,18 @@ func getTokenKey(tokenID []byte) []byte { // getNameState returns domain name state by the specified tokenID. func getNameState(ctx storage.Context, tokenID []byte) NameState { + return getFragmentedNameState(ctx, tokenID, nil) +} + +// getFragmentedNameState returns domain name state by the specified tokenID. +// Optional fragments parameter allows to pass pre-calculated elements of the +// domain name path: if empty, getFragmentedNameState splits name on its own. +func getFragmentedNameState(ctx storage.Context, tokenID []byte, fragments []string) NameState { tokenKey := getTokenKey(tokenID) ns := getNameStateWithKey(ctx, tokenKey) - fragments := std.StringSplit(string(tokenID), ".") + if len(fragments) == 0 { + fragments = std.StringSplit(string(tokenID), ".") + } if parentExpired(ctx, 1, fragments) { panic("parent domain has expired") }