diff --git a/container/container_contract.go b/container/container_contract.go index c3721238..abf210b0 100644 --- a/container/container_contract.go +++ b/container/container_contract.go @@ -295,8 +295,8 @@ func checkNiceNameAvailable(nnsContractAddr interop.Hash160, domain string) bool } res := contract.Call(nnsContractAddr, "getRecords", - contract.ReadStates|contract.AllowCall, domain, 16 /* TXT */) - if res != nil { + contract.ReadStates|contract.AllowCall, domain, 16 /* TXT */).([]string) + if len(res) > 0 { panic("name is already taken") } @@ -351,8 +351,8 @@ func Delete(containerID []byte, signature interop.Signature, token []byte) { // by other means (expiration, manual), thus leading to failing `deleteRecord` // and inability to delete a container. We should also check if we own the record in case. nnsContractAddr := storage.Get(ctx, nnsContractKey).(interop.Hash160) - res := contract.Call(nnsContractAddr, "getRecords", contract.ReadStates|contract.AllowCall, domain, 16 /* TXT */) - if res != nil && std.Base58Encode(containerID) == string(res.([]interface{})[0].(string)) { + res := contract.Call(nnsContractAddr, "getRecords", contract.ReadStates|contract.AllowCall, domain, 16 /* TXT */).([]string) + if len(res) > 0 && std.Base58Encode(containerID) == res[0] { contract.Call(nnsContractAddr, "deleteRecords", contract.All, domain, 16 /* TXT */) } } diff --git a/nns/nns.yml b/nns/nns.yml deleted file mode 100644 index 289793c9..00000000 --- a/nns/nns.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: "NameService" -supportedstandards: ["NEP-11"] -safemethods: ["balanceOf", "decimals", "symbol", "totalSupply", "tokensOf", "ownerOf", - "tokens", "properties", "roots", "getPrice", "isAvailable", "getRecord", - "resolve", "getAllRecords"] -events: - - name: Transfer - parameters: - - name: from - type: Hash160 - - name: to - type: Hash160 - - name: amount - type: Integer - - name: tokenId - type: ByteArray -permissions: - - hash: fffdc93764dbaddd97c48f252a53ea4643faa3fd - methods: ["update"] - - methods: ["onNEP11Payment"] diff --git a/nns/nns_contract.go b/nns/nns_contract.go index 8894294b..fae15a18 100644 --- a/nns/nns_contract.go +++ b/nns/nns_contract.go @@ -57,14 +57,19 @@ const ( maxDomainNameLength = 255 // maxTXTRecordLength is the maximum length of the TXT domain record. maxTXTRecordLength = 255 + // maxRecordID is the maximum value of record ID (the upper bound for the number + // of records with the same type). + maxRecordID = 255 ) // Other constants. const ( // defaultRegisterPrice is the default price for new domain registration. defaultRegisterPrice = 10_0000_0000 + // millisecondsInSecond is the amount of milliseconds per second. + millisecondsInSecond = 1000 // millisecondsInYear is amount of milliseconds per year. - millisecondsInYear = int64(365 * 24 * 3600 * 1000) + millisecondsInYear = int64(365 * 24 * 3600 * millisecondsInSecond) ) // RecordState is a type that registered entities are saved to. @@ -135,6 +140,7 @@ func Properties(tokenID []byte) map[string]interface{} { return map[string]interface{}{ "name": ns.Name, "expiration": ns.Expiration, + "admin": ns.Admin, } } @@ -220,7 +226,7 @@ func GetPrice() int { // IsAvailable checks whether the provided domain name is available. func IsAvailable(name string) bool { - fragments := splitAndCheck(name, false) + fragments := splitAndCheck(name, true) if fragments == nil { panic("invalid domain name format") } @@ -232,7 +238,27 @@ func IsAvailable(name string) bool { } return true } - return parentExpired(ctx, 0, fragments) + if !parentExpired(ctx, 0, fragments) { + return false + } + return len(getParentConflictingRecord(ctx, name, fragments)) == 0 +} + +// getPrentConflictingRecord returns record of '*.name' format if they are presented. +// These records conflict with domain name to be registered. +func getParentConflictingRecord(ctx storage.Context, name string, fragments []string) string { + parentKey := getTokenKey([]byte(name[len(fragments[0])+1:])) + 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) { + return r.Name + } + } + return "" } // parentExpired returns true if any domain from fragments doesn't exist or is expired. @@ -286,15 +312,8 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, 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) - } + if conflict := getParentConflictingRecord(ctx, name, fragments); len(conflict) != 0 { + panic("parent domain has conflicting records: " + conflict) } } @@ -322,7 +341,7 @@ func Register(name string, owner interop.Hash160, email string, refresh, retry, Owner: owner, Name: name, // NNS expiration is in milliseconds - Expiration: int64(runtime.GetTime() + expire*1000), + Expiration: int64(runtime.GetTime() + expire*millisecondsInSecond), } putNameStateWithKey(ctx, tokenKey, ns) putSoaRecord(ctx, name, email, refresh, retry, expire, ttl) @@ -371,52 +390,76 @@ func SetAdmin(name string, admin interop.Hash160) { putNameState(ctx, ns) } -// SetRecord adds a new record of the specified type to the provided domain. +// SetRecord updates existing domain record with the specified type and ID. func SetRecord(name string, typ RecordType, id byte, data string) { - tokenID := []byte(tokenIDFromName(name)) - if !checkBaseRecords(typ, data) { - panic("invalid record data") - } ctx := storage.GetContext() - ns := getNameState(ctx, tokenID) - ns.checkAdmin() - putRecord(ctx, tokenID, name, typ, id, data) - updateSoaSerial(ctx, tokenID) + tokenId := checkRecord(ctx, name, typ, data) + recordKey := getIdRecordKey(tokenId, name, typ, id) + recBytes := storage.Get(ctx, recordKey) + if recBytes == nil { + panic("invalid record id") + } + storeRecord(ctx, tokenId, name, typ, id, data) + updateSoaSerial(ctx, tokenId) } -func checkBaseRecords(typ RecordType, data string) bool { +// checkRecord performs record validness check and returns token ID. +func checkRecord(ctx storage.Context, name string, typ RecordType, data string) []byte { + tokenID := []byte(tokenIDFromName(ctx, name)) + var ok bool switch typ { case A: - return checkIPv4(data) + ok = checkIPv4(data) case CNAME: - return splitAndCheck(data, true) != nil + ok = splitAndCheck(data, true) != nil case TXT: - return len(data) <= maxTXTRecordLength + ok = len(data) <= maxTXTRecordLength case AAAA: - return checkIPv6(data) + ok = checkIPv6(data) default: panic("unsupported record type") } -} - -// AddRecord adds a new record of the specified type to the provided domain. -func AddRecord(name string, typ RecordType, data string) { - tokenID := []byte(tokenIDFromName(name)) - if !checkBaseRecords(typ, data) { + if !ok { panic("invalid record data") } - ctx := storage.GetContext() ns := getNameState(ctx, tokenID) ns.checkAdmin() - addRecord(ctx, tokenID, name, typ, data) - updateSoaSerial(ctx, tokenID) + return tokenID +} + +// AddRecord appends domain record to the list of domain records with the specified type +// if it doesn't exist yet. +func AddRecord(name string, typ RecordType, data string) { + ctx := storage.GetContext() + tokenId := checkRecord(ctx, name, typ, data) + recordsKey := getRecordsKeyByType(tokenId, name, typ) + var id byte + records := storage.Find(ctx, recordsKey, storage.ValuesOnly|storage.DeserializeValues) + for iterator.Next(records) { + id++ + + r := iterator.Value(records).(RecordState) + if r.Name == name && r.Type == typ && r.Data == data { + panic("record already exists") + } + } + if id > maxRecordID { + panic("maximum number of records reached") + } + + if typ == CNAME && id != 0 { + panic("you shouldn't have more than one CNAME record") + } + + storeRecord(ctx, tokenId, name, typ, id, data) + updateSoaSerial(ctx, tokenId) } // GetRecords returns domain record of the specified type if it exists or an empty // string if not. func GetRecords(name string, typ RecordType) []string { - tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetReadOnlyContext() + tokenID := []byte(tokenIDFromName(ctx, name)) _ = getNameState(ctx, tokenID) // ensure not expired return getRecordsByType(ctx, tokenID, name, typ) } @@ -426,8 +469,8 @@ func DeleteRecords(name string, typ RecordType) { if typ == SOA { panic("you cannot delete soa record") } - tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetContext() + tokenID := []byte(tokenIDFromName(ctx, name)) ns := getNameState(ctx, tokenID) ns.checkAdmin() recordsKey := getRecordsKeyByType(tokenID, name, typ) @@ -439,19 +482,17 @@ func DeleteRecords(name string, typ RecordType) { updateSoaSerial(ctx, tokenID) } -// Resolve resolves given name (not more then three redirects are allowed). +// Resolve resolves given name (not more than three redirects are allowed). func Resolve(name string, typ RecordType) []string { ctx := storage.GetReadOnlyContext() - return resolve(ctx, nil, name, typ, 2) + res := []string{} + return resolve(ctx, res, name, typ, 2) } // GetAllRecords returns an Iterator with RecordState items for the given name. func GetAllRecords(name string) iterator.Iterator { - tokenID := []byte(tokenIDFromName(name)) ctx := storage.GetReadOnlyContext() - _ = getNameState(ctx, tokenID) // ensure not expired - recordsKey := getRecordsKey(tokenID, name) - return storage.Find(ctx, recordsKey, storage.ValuesOnly|storage.DeserializeValues) + return getAllRecords(ctx, name) } // updateBalance updates account's balance and account's tokens. @@ -517,7 +558,7 @@ func getNameState(ctx storage.Context, tokenID []byte) NameState { // getNameStateWithKey returns domain name state by the specified token key. func getNameStateWithKey(ctx storage.Context, tokenKey []byte) NameState { - nameKey := append([]byte{prefixName}, tokenKey...) + nameKey := getNameStateKey(tokenKey) nsBytes := storage.Get(ctx, nameKey) if nsBytes == nil { panic("token not found") @@ -527,6 +568,11 @@ func getNameStateWithKey(ctx storage.Context, tokenKey []byte) NameState { return ns } +// getNameStateKey returns NameState key for the provided token key. +func getNameStateKey(tokenKey []byte) []byte { + return append([]byte{prefixName}, tokenKey...) +} + // putNameState stores domain name state. func putNameState(ctx storage.Context, ns NameState) { tokenKey := getTokenKey([]byte(ns.Name)) @@ -540,11 +586,11 @@ func putNameStateWithKey(ctx storage.Context, tokenKey []byte, ns NameState) { storage.Put(ctx, nameKey, nsBytes) } -// getRecordsByType returns domain record. +// getRecordsByType returns domain record. It returns empty array if no records found. func getRecordsByType(ctx storage.Context, tokenId []byte, name string, typ RecordType) []string { recordsKey := getRecordsKeyByType(tokenId, name, typ) - var result []string + result := []string{} records := storage.Find(ctx, recordsKey, storage.ValuesOnly|storage.DeserializeValues) for iterator.Next(records) { r := iterator.Value(records).(RecordState) @@ -555,42 +601,9 @@ func getRecordsByType(ctx storage.Context, tokenId []byte, name string, typ Reco return result } -// putRecord stores domain record. -func putRecord(ctx storage.Context, tokenId []byte, name string, typ RecordType, id byte, data string) { +// storeRecord puts record to storage and performs no additional checks. +func storeRecord(ctx storage.Context, tokenId []byte, name string, typ RecordType, id byte, data string) { recordKey := getIdRecordKey(tokenId, name, typ, id) - recBytes := storage.Get(ctx, recordKey) - if recBytes == nil { - panic("invalid record id") - } - - storeRecord(ctx, recordKey, name, typ, id, data) -} - -// addRecord stores domain record. -func addRecord(ctx storage.Context, tokenId []byte, name string, typ RecordType, data string) { - recordsKey := getRecordsKeyByType(tokenId, name, typ) - - var id byte - records := storage.Find(ctx, recordsKey, storage.ValuesOnly|storage.DeserializeValues) - for iterator.Next(records) { - id++ - - r := iterator.Value(records).(RecordState) - if r.Name == name && r.Type == typ && r.Data == data { - panic("record already exists") - } - } - - if typ == CNAME && id != 0 { - panic("you shouldn't have more than one CNAME record") - } - - recordKey := append(recordsKey, id) // the same as getIdRecordKey - storeRecord(ctx, recordKey, name, typ, id, data) -} - -// storeRecord puts record to storage. -func storeRecord(ctx storage.Context, recordKey []byte, name string, typ RecordType, id byte, data string) { rs := RecordState{ Name: name, Type: typ, @@ -603,22 +616,14 @@ func storeRecord(ctx storage.Context, recordKey []byte, name string, typ RecordT // putSoaRecord stores soa domain record. func putSoaRecord(ctx storage.Context, name, email string, refresh, retry, expire, ttl int) { - var id byte - tokenId := []byte(tokenIDFromName(name)) - recordKey := getIdRecordKey(tokenId, name, SOA, id) - rs := RecordState{ - Name: name, - Type: SOA, - ID: id, - Data: name + " " + email + " " + - std.Itoa(runtime.GetTime(), 10) + " " + - std.Itoa(refresh, 10) + " " + - std.Itoa(retry, 10) + " " + - std.Itoa(expire, 10) + " " + - std.Itoa(ttl, 10), - } - recBytes := std.Serialize(rs) - storage.Put(ctx, recordKey, recBytes) + tokenId := []byte(tokenIDFromName(ctx, name)) + data := name + " " + email + " " + + std.Itoa(runtime.GetTime(), 10) + " " + + std.Itoa(refresh, 10) + " " + + std.Itoa(retry, 10) + " " + + std.Itoa(expire, 10) + " " + + std.Itoa(ttl, 10) + storeRecord(ctx, tokenId, name, SOA, 0, data) } // updateSoaSerial stores soa domain record. @@ -846,18 +851,17 @@ func checkIPv6(data string) bool { } // tokenIDFromName returns token ID (domain.root) from the provided name. -func tokenIDFromName(name string) string { +func tokenIDFromName(ctx storage.Context, name string) string { fragments := splitAndCheck(name, true) if fragments == nil { panic("invalid domain name format") } - ctx := storage.GetReadOnlyContext() sum := 0 l := len(fragments) - 1 for i := 0; i < l; i++ { tokenKey := getTokenKey([]byte(name[sum:])) - nameKey := append([]byte{prefixName}, tokenKey...) + nameKey := getNameStateKey(tokenKey) nsBytes := storage.Get(ctx, nameKey) if nsBytes != nil { ns := std.Deserialize(nsBytes.([]byte)).(NameState) @@ -897,15 +901,14 @@ func resolve(ctx storage.Context, res []string, name string, typ RecordType, red return res } - res = append(res, cname) return resolve(ctx, res, cname, typ, redirect-1) } // getAllRecords returns iterator over the set of records corresponded with the // specified name. func getAllRecords(ctx storage.Context, name string) iterator.Iterator { - tokenID := []byte(tokenIDFromName(name)) - _ = getNameState(ctx, tokenID) + tokenID := []byte(tokenIDFromName(ctx, name)) + _ = getNameState(ctx, tokenID) // ensure not expired. recordsKey := getRecordsKey(tokenID, name) return storage.Find(ctx, recordsKey, storage.ValuesOnly|storage.DeserializeValues) } diff --git a/tests/container_test.go b/tests/container_test.go index 9a265e17..d26221c5 100644 --- a/tests/container_test.go +++ b/tests/container_test.go @@ -153,7 +153,7 @@ func TestContainerPut(t *testing.T) { }) c.Invoke(t, stackitem.Null{}, "delete", cnt.id[:], cnt.sig, cnt.token) - cNNS.Invoke(t, stackitem.Null{}, "resolve", "mycnt.neofs", int64(nns.TXT)) + cNNS.Invoke(t, stackitem.NewArray([]stackitem.Item{}), "resolve", "mycnt.neofs", int64(nns.TXT)) t.Run("register in advance", func(t *testing.T) { cnt.value[len(cnt.value)-1] = 10 diff --git a/tests/nns_test.go b/tests/nns_test.go index 2f8049ca..bac6c1d3 100644 --- a/tests/nns_test.go +++ b/tests/nns_test.go @@ -4,6 +4,7 @@ import ( "fmt" "math/big" "path" + "strconv" "strings" "testing" "time" @@ -17,7 +18,10 @@ import ( const nnsPath = "../nns" -const msPerYear = 365 * 24 * time.Hour / time.Millisecond +const ( + msPerYear = 365 * 24 * time.Hour / time.Millisecond + maxRecordID = 255 // value from the contract. +) func newNNSInvoker(t *testing.T, addRoot bool) *neotest.ContractInvoker { e := newExecutor(t) @@ -174,11 +178,13 @@ func TestNNSRegisterMulti(t *testing.T) { "another.fs.neo.com", int64(nns.A), "4.3.2.1") c2 = c.WithSigners(acc, acc2) + c2.Invoke(t, stackitem.NewBool(false), "isAvailable", "mainnet.fs.neo.com") c2.InvokeFail(t, "parent domain has conflicting records: something.mainnet.fs.neo.com", "register", args...) c1.Invoke(t, stackitem.Null{}, "deleteRecords", "something.mainnet.fs.neo.com", int64(nns.A)) + c2.Invoke(t, stackitem.NewBool(true), "isAvailable", "mainnet.fs.neo.com") c2.Invoke(t, true, "register", args...) c2 = c.WithSigners(acc2) @@ -251,6 +257,23 @@ func TestNNSGetAllRecords(t *testing.T) { require.False(t, iter.Next()) } +func TestNNSGetRecords(t *testing.T) { + c := newNNSInvoker(t, true) + + refresh, retry, expire, ttl := int64(101), int64(102), int64(103), int64(104) + c.Invoke(t, true, "register", + "testdomain.com", c.CommitteeHash, + "myemail@nspcc.ru", refresh, retry, expire, ttl) + + txtData := "first TXT record" + c.Invoke(t, stackitem.Null{}, "addRecord", "testdomain.com", int64(nns.TXT), txtData) + c.Invoke(t, stackitem.Null{}, "addRecord", "testdomain.com", int64(nns.A), "1.2.3.4") + + c.Invoke(t, stackitem.NewArray([]stackitem.Item{stackitem.Make(txtData)}), "getRecords", "testdomain.com", int64(nns.TXT)) + // Check empty result. + c.Invoke(t, stackitem.NewArray([]stackitem.Item{}), "getRecords", "testdomain.com", int64(nns.AAAA)) +} + func TestExpiration(t *testing.T) { c := newNNSInvoker(t, true) @@ -262,7 +285,8 @@ func TestExpiration(t *testing.T) { checkProperties := func(t *testing.T, expiration uint64) { expected := stackitem.NewMapWithValue([]stackitem.MapElement{ {Key: stackitem.Make("name"), Value: stackitem.Make("testdomain.com")}, - {Key: stackitem.Make("expiration"), Value: stackitem.Make(expiration)}}) + {Key: stackitem.Make("expiration"), Value: stackitem.Make(expiration)}, + {Key: stackitem.Make("admin"), Value: stackitem.Null{}}}) s, err := c.TestInvoke(t, "properties", "testdomain.com") require.NoError(t, err) require.Equal(t, expected.Value(), s.Top().Item().Value()) @@ -296,6 +320,7 @@ func TestNNSSetAdmin(t *testing.T) { c.Invoke(t, true, "register", "testdomain.com", c.CommitteeHash, "myemail@nspcc.ru", refresh, retry, expire, ttl) + top := c.TopBlock(t) acc := c.NewAccount(t) cAcc := c.WithSigners(acc) @@ -305,6 +330,13 @@ func TestNNSSetAdmin(t *testing.T) { c1 := c.WithSigners(c.Committee, acc) c1.Invoke(t, stackitem.Null{}, "setAdmin", "testdomain.com", acc.ScriptHash()) + expiration := top.Timestamp + uint64(expire*1000) + expectedProps := stackitem.NewMapWithValue([]stackitem.MapElement{ + {Key: stackitem.Make("name"), Value: stackitem.Make("testdomain.com")}, + {Key: stackitem.Make("expiration"), Value: stackitem.Make(expiration)}, + {Key: stackitem.Make("admin"), Value: stackitem.Make(acc.ScriptHash().BytesBE())}}) + cAcc.Invoke(t, expectedProps, "properties", "testdomain.com") + cAcc.Invoke(t, stackitem.Null{}, "addRecord", "testdomain.com", int64(nns.TXT), "will be added") } @@ -351,7 +383,8 @@ func TestNNSRenew(t *testing.T) { c1.Invoke(t, ts, "renew", "testdomain.com") expected := stackitem.NewMapWithValue([]stackitem.MapElement{ {Key: stackitem.Make("name"), Value: stackitem.Make("testdomain.com")}, - {Key: stackitem.Make("expiration"), Value: stackitem.Make(ts)}}) + {Key: stackitem.Make("expiration"), Value: stackitem.Make(ts)}, + {Key: stackitem.Make("admin"), Value: stackitem.Null{}}}) cAcc.Invoke(t, expected, "properties", "testdomain.com") } @@ -359,15 +392,43 @@ func TestNNSResolve(t *testing.T) { c := newNNSInvoker(t, true) refresh, retry, expire, ttl := int64(101), int64(102), int64(103), int64(104) - c.Invoke(t, true, "register", - "test.com", c.CommitteeHash, - "myemail@nspcc.ru", refresh, retry, expire, ttl) + c.Invoke(t, true, "register", "test.com", c.CommitteeHash, "myemail@nspcc.ru", refresh, retry, expire, ttl) + c.Invoke(t, stackitem.Null{}, "addRecord", "test.com", int64(nns.TXT), "expected result") + c.Invoke(t, stackitem.Null{}, "addRecord", "test.com", int64(nns.CNAME), "alias.com") - c.Invoke(t, stackitem.Null{}, "addRecord", - "test.com", int64(nns.TXT), "expected result") + c.Invoke(t, true, "register", "alias.com", c.CommitteeHash, "myemail@nspcc.ru", refresh, retry, expire, ttl) + c.Invoke(t, stackitem.Null{}, "addRecord", "alias.com", int64(nns.A), "1.2.3.4") + c.Invoke(t, stackitem.Null{}, "addRecord", "alias.com", int64(nns.CNAME), "alias2.com") + + c.Invoke(t, true, "register", "alias2.com", c.CommitteeHash, "myemail@nspcc.ru", refresh, retry, expire, ttl) + c.Invoke(t, stackitem.Null{}, "addRecord", "alias2.com", int64(nns.A), "5.6.7.8") records := stackitem.NewArray([]stackitem.Item{stackitem.Make("expected result")}) c.Invoke(t, records, "resolve", "test.com", int64(nns.TXT)) c.Invoke(t, records, "resolve", "test.com.", int64(nns.TXT)) c.InvokeFail(t, "invalid domain name format", "resolve", "test.com..", int64(nns.TXT)) + + // Empty result. + c.Invoke(t, stackitem.NewArray([]stackitem.Item{}), "resolve", "test.com", int64(nns.AAAA)) + + // Check CNAME is properly resolved and is not included into the result list. + c.Invoke(t, stackitem.NewArray([]stackitem.Item{stackitem.Make("1.2.3.4"), stackitem.Make("5.6.7.8")}), "resolve", "test.com", int64(nns.A)) + // And this time it should be properly included without resolution. + c.Invoke(t, stackitem.NewArray([]stackitem.Item{stackitem.Make("alias.com")}), "resolve", "test.com", int64(nns.CNAME)) +} + +func TestNNSAddRecord(t *testing.T) { + c := newNNSInvoker(t, true) + + refresh, retry, expire, ttl := int64(101), int64(102), int64(103), int64(104) + c.Invoke(t, true, "register", + "testdomain.com", c.CommitteeHash, + "myemail@nspcc.ru", refresh, retry, expire, ttl) + for i := 0; i <= maxRecordID+1; i++ { + if i == maxRecordID+1 { + c.InvokeFail(t, "maximum number of records reached", "addRecord", "testdomain.com", int64(nns.TXT), strconv.Itoa(i)) + } else { + c.Invoke(t, stackitem.Null{}, "addRecord", "testdomain.com", int64(nns.TXT), strconv.Itoa(i)) + } + } }