diff --git a/pkg/core/helper_test.go b/pkg/core/helper_test.go index e85d8112a..9545c8499 100644 --- a/pkg/core/helper_test.go +++ b/pkg/core/helper_test.go @@ -93,8 +93,8 @@ func newBlock(cfg config.ProtocolConfiguration, index uint32, prev util.Uint256, return newBlockWithState(cfg, index, prev, nil, txs...) } -func newBlockWithState(cfg config.ProtocolConfiguration, index uint32, prev util.Uint256, - prevState *util.Uint256, txs ...*transaction.Transaction) *block.Block { +func newBlockCustom(cfg config.ProtocolConfiguration, f func(b *block.Block), + txs ...*transaction.Transaction) *block.Block { validators, _ := validatorsFromConfig(cfg) valScript, _ := smartcontract.CreateDefaultMultiSigRedeemScript(validators) witness := transaction.Witness{ @@ -103,10 +103,6 @@ func newBlockWithState(cfg config.ProtocolConfiguration, index uint32, prev util b := &block.Block{ Base: block.Base{ Network: testchain.Network(), - Version: 0, - PrevHash: prev, - Timestamp: uint64(time.Now().UTC().Unix())*1000 + uint64(index), - Index: index, NextConsensus: witness.ScriptHash(), Script: witness, }, @@ -116,15 +112,27 @@ func newBlockWithState(cfg config.ProtocolConfiguration, index uint32, prev util }, Transactions: txs, } - if prevState != nil { - b.StateRootEnabled = true - b.PrevStateRoot = *prevState - } + f(b) + b.RebuildMerkleRoot() b.Script.InvocationScript = testchain.Sign(b.GetSignedPart()) return b } +func newBlockWithState(cfg config.ProtocolConfiguration, index uint32, prev util.Uint256, + prevState *util.Uint256, txs ...*transaction.Transaction) *block.Block { + return newBlockCustom(cfg, func(b *block.Block) { + b.PrevHash = prev + b.Timestamp = uint64(time.Now().UTC().Unix())*1000 + uint64(index) + b.Index = index + + if prevState != nil { + b.StateRootEnabled = true + b.PrevStateRoot = *prevState + } + }, txs...) +} + func (bc *Blockchain) genBlocks(n int) ([]*block.Block, error) { blocks := make([]*block.Block, n) lastHash := bc.topBlock.Load().(*block.Block).Hash() @@ -433,8 +441,10 @@ func addNetworkFee(bc *Blockchain, tx *transaction.Transaction, sender *wallet.A return nil } +// Signer can be either bool or *wallet.Account. +// In the first case `true` means sign by committee, `false` means sign by validators. func prepareContractMethodInvokeGeneric(chain *Blockchain, sysfee int64, - hash util.Uint160, method string, isCommittee bool, args ...interface{}) (*transaction.Transaction, error) { + hash util.Uint160, method string, signer interface{}, args ...interface{}) (*transaction.Transaction, error) { w := io.NewBufBinWriter() emit.AppCall(w.BinWriter, hash, method, callflag.All, args...) if w.Err != nil { @@ -444,12 +454,21 @@ func prepareContractMethodInvokeGeneric(chain *Blockchain, sysfee int64, tx := transaction.New(chain.GetConfig().Magic, script, sysfee) tx.ValidUntilBlock = chain.blockHeight + 1 var err error - if isCommittee { - addSigners(testchain.CommitteeScriptHash(), tx) - err = testchain.SignTxCommittee(chain, tx) - } else { - addSigners(neoOwner, tx) - err = testchain.SignTx(chain, tx) + switch s := signer.(type) { + case bool: + if s { + addSigners(testchain.CommitteeScriptHash(), tx) + err = testchain.SignTxCommittee(chain, tx) + } else { + addSigners(neoOwner, tx) + err = testchain.SignTx(chain, tx) + } + case *wallet.Account: + signTxWithAccounts(chain, tx, s) + case []*wallet.Account: + signTxWithAccounts(chain, tx, s...) + default: + panic("invalid signer") } if err != nil { return nil, err @@ -457,6 +476,30 @@ func prepareContractMethodInvokeGeneric(chain *Blockchain, sysfee int64, return tx, nil } +func signTxWithAccounts(chain *Blockchain, tx *transaction.Transaction, accs ...*wallet.Account) { + scope := transaction.CalledByEntry + for _, acc := range accs { + tx.Signers = append(tx.Signers, transaction.Signer{ + Account: acc.PrivateKey().GetScriptHash(), + Scopes: scope, + }) + scope = transaction.Global + } + size := io.GetVarSize(tx) + for _, acc := range accs { + netFee, sizeDelta := fee.Calculate(chain.GetBaseExecFee(), acc.Contract.Script) + size += sizeDelta + tx.NetworkFee += netFee + } + tx.NetworkFee += int64(size) * chain.FeePerByte() + + for _, acc := range accs { + if err := acc.SignTx(tx); err != nil { + panic(err) + } + } +} + func prepareContractMethodInvoke(chain *Blockchain, sysfee int64, hash util.Uint160, method string, args ...interface{}) (*transaction.Transaction, error) { return prepareContractMethodInvokeGeneric(chain, sysfee, hash, @@ -486,9 +529,9 @@ func invokeContractMethod(chain *Blockchain, sysfee int64, hash util.Uint160, me } func invokeContractMethodGeneric(chain *Blockchain, sysfee int64, hash util.Uint160, method string, - isCommittee bool, args ...interface{}) (*state.AppExecResult, error) { + signer interface{}, args ...interface{}) (*state.AppExecResult, error) { tx, err := prepareContractMethodInvokeGeneric(chain, sysfee, hash, - method, isCommittee, args...) + method, signer, args...) if err != nil { return nil, err } @@ -509,26 +552,7 @@ func invokeContractMethodBy(t *testing.T, chain *Blockchain, signer *wallet.Acco require.NoError(t, err) require.Equal(t, vm.HaltState, res[0].VMState) require.Equal(t, 0, len(res[0].Stack)) - - w := io.NewBufBinWriter() - emit.AppCall(w.BinWriter, hash, method, callflag.All, args...) - if w.Err != nil { - return nil, w.Err - } - script := w.Bytes() - tx := transaction.New(chain.GetConfig().Magic, script, sysfee) - tx.ValidUntilBlock = chain.blockHeight + 1 - tx.Signers = []transaction.Signer{ - {Account: signer.PrivateKey().PublicKey().GetScriptHash()}, - } - tx.NetworkFee = netfee - err = signer.SignTx(tx) - require.NoError(t, err) - require.NoError(t, chain.AddBlock(chain.newBlock(tx))) - - res, err = chain.GetAppExecResults(tx.Hash(), trigger.Application) - require.NoError(t, err) - return &res[0], nil + return invokeContractMethodGeneric(chain, sysfee, hash, method, signer, args...) } func transferTokenFromMultisigAccount(t *testing.T, chain *Blockchain, to, tokenHash util.Uint160, amount int64, additionalArgs ...interface{}) *transaction.Transaction { diff --git a/pkg/core/native/contract.go b/pkg/core/native/contract.go index ad76f3fa8..567042446 100644 --- a/pkg/core/native/contract.go +++ b/pkg/core/native/contract.go @@ -15,14 +15,15 @@ const reservedContractID = -100 // Contracts is a set of registered native contracts. type Contracts struct { - Management *Management - NEO *NEO - GAS *GAS - Policy *Policy - Oracle *Oracle - Designate *Designate - Notary *Notary - Contracts []interop.Contract + Management *Management + NEO *NEO + GAS *GAS + Policy *Policy + Oracle *Oracle + Designate *Designate + NameService *NameService + Notary *Notary + Contracts []interop.Contract // persistScript is vm script which executes "onPersist" method of every native contract. persistScript []byte // postPersistScript is vm script which executes "postPersist" method of every native contract. @@ -87,6 +88,11 @@ func NewContracts(p2pSigExtensionsEnabled bool) *Contracts { cs.Oracle = oracle cs.Contracts = append(cs.Contracts, oracle) + ns := newNameService() + ns.NEO = neo + cs.NameService = ns + cs.Contracts = append(cs.Contracts, ns) + if p2pSigExtensionsEnabled { notary := newNotary() notary.GAS = gas diff --git a/pkg/core/native/name_service.go b/pkg/core/native/name_service.go new file mode 100644 index 000000000..0690cb414 --- /dev/null +++ b/pkg/core/native/name_service.go @@ -0,0 +1,749 @@ +package native + +import ( + "encoding/binary" + "errors" + "math" + "math/big" + "net" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/nspcc-dev/neo-go/pkg/core/dao" + "github.com/nspcc-dev/neo-go/pkg/core/interop" + "github.com/nspcc-dev/neo-go/pkg/core/interop/runtime" + "github.com/nspcc-dev/neo-go/pkg/core/native/nativenames" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/crypto/hash" + "github.com/nspcc-dev/neo-go/pkg/encoding/bigint" + "github.com/nspcc-dev/neo-go/pkg/io" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" +) + +// NameService represents native NameService contract. +type NameService struct { + nonfungible + NEO *NEO +} + +type nameState struct { + state.NFTTokenState + // Expiration is token expiration height. + Expiration uint32 + // HasAdmin is true if token has admin. + HasAdmin bool + // Admin is token admin. + Admin util.Uint160 +} + +// RecordType represents name record type. +type RecordType byte + +// Pre-defined record types. +const ( + RecordTypeA RecordType = 1 + RecordTypeCNAME RecordType = 5 + RecordTypeTXT RecordType = 16 + RecordTypeAAAA RecordType = 28 +) + +const ( + nameServiceID = -7 + + prefixRoots = 10 + prefixDomainPrice = 22 + prefixExpiration = 20 + prefixRecord = 12 + + secondsInYear = 365 * 24 * 3600 + + // DefaultDomainPrice is the default price of register method. + DefaultDomainPrice = 10_00000000 + // MinDomainNameLength is minimum domain length. + MinDomainNameLength = 3 + // MaxDomainNameLength is maximum domain length. + MaxDomainNameLength = 255 +) + +var ( + // Lookahead is not supported by Go, but it is simple `(?=.{3,255}$)`, + // so we check name length explicitly. + nameRegex = regexp.MustCompile("^([a-z0-9]{1,62}\\.)+[a-z][a-z0-9]{0,15}$") + ipv4Regex = regexp.MustCompile("^(2(5[0-5]|[0-4]\\d))|1?\\d{1,2}(\\.((2(5[0-5]|[0-4]\\d))|1?\\d{1,2})){3}$") + ipv6Regex = regexp.MustCompile("^([a-f0-9A-F]{1,4}:){7}[a-f0-9A-F]{1,4}$") + rootRegex = regexp.MustCompile("^[a-z][a-z0-9]{0,15}$") +) + +// matchName checks if provided name is valid. +func matchName(name string) bool { + ln := len(name) + return MinDomainNameLength <= ln && ln <= MaxDomainNameLength && + nameRegex.Match([]byte(name)) +} + +func newNameService() *NameService { + nf := newNonFungible(nativenames.NameService, nameServiceID, "NNS", 0) + nf.getTokenKey = func(tokenID []byte) []byte { + return append([]byte{prefixNFTToken}, hash.Hash160(tokenID).BytesBE()...) + } + nf.newTokenState = func() nftTokenState { + return new(nameState) + } + nf.onTransferred = func(tok nftTokenState) { + tok.(*nameState).HasAdmin = false + } + + n := &NameService{nonfungible: *nf} + + desc := newDescriptor("addRoot", smartcontract.VoidType, + manifest.NewParameter("root", smartcontract.StringType)) + md := newMethodAndPrice(n.addRoot, 3000000, callflag.WriteStates) + n.AddMethod(md, desc) + + desc = newDescriptor("setPrice", smartcontract.VoidType, + manifest.NewParameter("price", smartcontract.IntegerType)) + md = newMethodAndPrice(n.setPrice, 3000000, callflag.WriteStates) + n.AddMethod(md, desc) + + desc = newDescriptor("getPrice", smartcontract.IntegerType) + md = newMethodAndPrice(n.getPrice, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("isAvailable", smartcontract.BoolType, + manifest.NewParameter("name", smartcontract.StringType)) + md = newMethodAndPrice(n.isAvailable, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("register", smartcontract.BoolType, + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("owner", smartcontract.Hash160Type)) + md = newMethodAndPrice(n.register, 1000000, callflag.WriteStates) + n.AddMethod(md, desc) + + desc = newDescriptor("renew", smartcontract.IntegerType, + manifest.NewParameter("name", smartcontract.StringType)) + md = newMethodAndPrice(n.renew, 0, callflag.WriteStates) + n.AddMethod(md, desc) + + desc = newDescriptor("setAdmin", smartcontract.VoidType, + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("admin", smartcontract.Hash160Type)) + md = newMethodAndPrice(n.setAdmin, 3000000, callflag.WriteStates) + n.AddMethod(md, desc) + + desc = newDescriptor("setRecord", smartcontract.VoidType, + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("type", smartcontract.IntegerType), + manifest.NewParameter("data", smartcontract.StringType)) + md = newMethodAndPrice(n.setRecord, 30000000, callflag.WriteStates) + n.AddMethod(md, desc) + + desc = newDescriptor("getRecord", smartcontract.StringType, + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("type", smartcontract.IntegerType)) + md = newMethodAndPrice(n.getRecord, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("deleteRecord", smartcontract.VoidType, + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("type", smartcontract.IntegerType)) + md = newMethodAndPrice(n.deleteRecord, 1000000, callflag.WriteStates) + n.AddMethod(md, desc) + + desc = newDescriptor("resolve", smartcontract.StringType, + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("type", smartcontract.IntegerType)) + md = newMethodAndPrice(n.resolve, 3000000, callflag.ReadStates) + n.AddMethod(md, desc) + + return n +} + +// Metadata implements interop.Contract interface. +func (n *NameService) Metadata() *interop.ContractMD { + return &n.ContractMD +} + +// Initialize implements interop.Contract interface. +func (n *NameService) Initialize(ic *interop.Context) error { + si := &state.StorageItem{Value: bigint.ToBytes(big.NewInt(DefaultDomainPrice))} + if err := ic.DAO.PutStorageItem(n.ContractID, []byte{prefixDomainPrice}, si); err != nil { + return err + } + + roots := stringList{} + return putSerializableToDAO(n.ContractID, ic.DAO, []byte{prefixRoots}, &roots) +} + +// OnPersist implements interop.Contract interface. +func (n *NameService) OnPersist(ic *interop.Context) error { + now := uint32(ic.Block.Timestamp/1000 + 1) + keys := []string{} + ic.DAO.Seek(n.ContractID, []byte{prefixExpiration}, func(k, v []byte) { + if binary.BigEndian.Uint32(k) >= now { + return + } + // Removal is done separately because of `Seek` takes storage mutex. + keys = append(keys, string(k)) + }) + + var keysToRemove [][]byte + key := []byte{prefixExpiration} + keyRecord := []byte{prefixRecord} + for i := range keys { + key[0] = prefixExpiration + key = append(key[:1], []byte(keys[i])...) + if err := ic.DAO.DeleteStorageItem(n.ContractID, key); err != nil { + return err + } + + keysToRemove = keysToRemove[:0] + key[0] = prefixRecord + key = append(key[:1], keys[i][4:]...) + ic.DAO.Seek(n.ContractID, key, func(k, v []byte) { + keysToRemove = append(keysToRemove, k) + }) + for i := range keysToRemove { + keyRecord = append(keyRecord[:0], key...) + keyRecord = append(keyRecord, keysToRemove[i]...) + err := ic.DAO.DeleteStorageItem(n.ContractID, keyRecord) + if err != nil { + return err + } + } + + key[0] = prefixNFTToken + n.burnByKey(ic, key) + } + return nil +} + +// PostPersist implements interop.Contract interface. +func (n *NameService) PostPersist(ic *interop.Context) error { + return nil +} + +func (n *NameService) addRoot(ic *interop.Context, args []stackitem.Item) stackitem.Item { + root := toString(args[0]) + if !rootRegex.Match([]byte(root)) { + panic("invalid root") + } + + n.checkCommittee(ic) + roots, _ := n.getRootsInternal(ic.DAO) + if !roots.add(root) { + panic("name already exists") + } + + err := putSerializableToDAO(n.ContractID, ic.DAO, []byte{prefixRoots}, &roots) + if err != nil { + panic(err) + } + return stackitem.Null{} +} + +var maxPrice = big.NewInt(10000_00000000) + +func (n *NameService) setPrice(ic *interop.Context, args []stackitem.Item) stackitem.Item { + price := toBigInt(args[0]) + if price.Sign() <= 0 || price.Cmp(maxPrice) >= 0 { + panic("invalid price") + } + + n.checkCommittee(ic) + si := &state.StorageItem{Value: bigint.ToBytes(price)} + err := ic.DAO.PutStorageItem(n.ContractID, []byte{prefixDomainPrice}, si) + if err != nil { + panic(err) + } + return stackitem.Null{} +} + +func (n *NameService) getPrice(ic *interop.Context, _ []stackitem.Item) stackitem.Item { + return stackitem.NewBigInteger(n.getPriceInternal(ic.DAO)) +} + +func (n *NameService) getPriceInternal(d dao.DAO) *big.Int { + si := d.GetStorageItem(n.ContractID, []byte{prefixDomainPrice}) + return bigint.FromBytes(si.Value) +} + +func (n *NameService) parseName(item stackitem.Item) (string, []string, []byte) { + name := toName(item) + names := strings.Split(name, ".") + if len(names) != 2 { + panic("invalid name") + } + return name, names, n.getTokenKey([]byte(name)) +} + +func (n *NameService) isAvailable(ic *interop.Context, args []stackitem.Item) stackitem.Item { + _, names, key := n.parseName(args[0]) + if ic.DAO.GetStorageItem(n.ContractID, key) != nil { + return stackitem.NewBool(false) + } + + roots, _ := n.getRootsInternal(ic.DAO) + _, ok := roots.index(names[1]) + if !ok { + panic("domain is not registered") + } + return stackitem.NewBool(true) +} + +func (n *NameService) getRootsInternal(d dao.DAO) (stringList, bool) { + var sl stringList + err := getSerializableFromDAO(n.ContractID, d, []byte{prefixRoots}, &sl) + if err != nil { + // Roots are being stored in `Initialize()` and thus must always be present. + panic(err) + } + return sl, true +} + +func (n *NameService) register(ic *interop.Context, args []stackitem.Item) stackitem.Item { + name, names, key := n.parseName(args[0]) + owner := toUint160(args[1]) + if !n.checkWitness(ic, owner) { + panic("owner is not witnessed") + } + + if ic.DAO.GetStorageItem(n.ContractID, key) != nil { + return stackitem.NewBool(false) + } + + roots, _ := n.getRootsInternal(ic.DAO) + if _, ok := roots.index(names[1]); !ok { + panic("missing root") + } + if !ic.VM.AddGas(n.getPriceInternal(ic.DAO).Int64()) { + panic("insufficient gas") + } + token := &nameState{ + NFTTokenState: state.NFTTokenState{ + Owner: owner, + Name: name, + }, + Expiration: uint32(ic.Block.Timestamp/1000 + secondsInYear), + } + n.mint(ic, token) + err := ic.DAO.PutStorageItem(n.ContractID, + makeExpirationKey(token.Expiration, token.ID()), + &state.StorageItem{Value: []byte{0}}) + if err != nil { + panic(err) + } + return stackitem.NewBool(true) +} + +func (n *NameService) renew(ic *interop.Context, args []stackitem.Item) stackitem.Item { + _, _, key := n.parseName(args[0]) + if !ic.VM.AddGas(n.getPriceInternal(ic.DAO).Int64()) { + panic("insufficient gas") + } + token := new(nameState) + err := getSerializableFromDAO(n.ContractID, ic.DAO, key, token) + if err != nil { + panic(err) + } + + keyExpiration := makeExpirationKey(token.Expiration, token.ID()) + if err := ic.DAO.DeleteStorageItem(n.ContractID, keyExpiration); err != nil { + panic(err) + } + + token.Expiration += secondsInYear + err = putSerializableToDAO(n.ContractID, ic.DAO, key, token) + if err != nil { + panic(err) + } + + binary.BigEndian.PutUint32(key[1:], token.Expiration) + si := &state.StorageItem{Value: []byte{0}} + err = ic.DAO.PutStorageItem(n.ContractID, key, si) + if err != nil { + panic(err) + } + bi := new(big.Int).SetUint64(uint64(token.Expiration)) + return stackitem.NewBigInteger(bi) +} + +func (n *NameService) setAdmin(ic *interop.Context, args []stackitem.Item) stackitem.Item { + _, _, key := n.parseName(args[0]) + + var admin util.Uint160 + _, isNull := args[1].(stackitem.Null) + if !isNull { + admin = toUint160(args[1]) + if !n.checkWitness(ic, admin) { + panic("not witnessed by admin") + } + } + + token := new(nameState) + err := getSerializableFromDAO(n.ContractID, ic.DAO, key, token) + if err != nil { + panic(err) + } + if !n.checkWitness(ic, token.Owner) { + panic("only owner can set admin") + } + token.HasAdmin = !isNull + token.Admin = admin + err = putSerializableToDAO(n.ContractID, ic.DAO, key, token) + if err != nil { + panic(err) + } + return stackitem.Null{} +} + +func (n *NameService) checkWitness(ic *interop.Context, owner util.Uint160) bool { + ok, err := runtime.CheckHashedWitness(ic, owner) + if err != nil { + panic(err) + } + return ok +} + +func (n *NameService) checkCommittee(ic *interop.Context) { + if !n.NEO.checkCommittee(ic) { + panic("not witnessed by committee") + } +} + +func (n *NameService) checkAdmin(ic *interop.Context, token *nameState) bool { + if n.checkWitness(ic, token.Owner) { + return true + } + return token.HasAdmin && n.checkWitness(ic, token.Admin) +} + +func (n *NameService) setRecord(ic *interop.Context, args []stackitem.Item) stackitem.Item { + name := toName(args[0]) + rt := toRecordType(args[1]) + data := toString(args[2]) + n.checkName(rt, data) + + domain := toDomain(name) + token, _, err := n.tokenState(ic.DAO, []byte(domain)) + if err != nil { + panic(err) + } + if !n.checkAdmin(ic, token.(*nameState)) { + panic("not witnessed by admin") + } + key := makeRecordKey(domain, name, rt) + si := &state.StorageItem{Value: []byte(data)} + if err := ic.DAO.PutStorageItem(n.ContractID, key, si); err != nil { + panic(err) + } + return stackitem.Null{} +} + +func (n *NameService) checkName(rt RecordType, name string) { + var valid bool + switch rt { + case RecordTypeA: + // We can't rely on `len(ip) == net.IPv4len` because + // IPv4 can be parsed to mapped representation. + valid = ipv4Regex.MatchString(name) && + net.ParseIP(name) != nil + case RecordTypeCNAME: + valid = matchName(name) + case RecordTypeTXT: + valid = utf8.RuneCountInString(name) <= 255 + case RecordTypeAAAA: + valid = ipv6Regex.MatchString(name) && + net.ParseIP(name) != nil + } + if !valid { + panic("invalid name") + } +} + +func (n *NameService) getRecord(ic *interop.Context, args []stackitem.Item) stackitem.Item { + name := toName(args[0]) + domain := toDomain(name) + rt := toRecordType(args[1]) + key := makeRecordKey(domain, name, rt) + si := ic.DAO.GetStorageItem(n.ContractID, key) + if si == nil { + return stackitem.Null{} + } + return stackitem.NewByteArray(si.Value) +} + +func (n *NameService) deleteRecord(ic *interop.Context, args []stackitem.Item) stackitem.Item { + name := toName(args[0]) + rt := toRecordType(args[1]) + domain := toDomain(name) + key := n.getTokenKey([]byte(domain)) + token := new(nameState) + err := getSerializableFromDAO(n.ContractID, ic.DAO, key, token) + if err != nil { + panic(err) + } + + if !n.checkAdmin(ic, token) { + panic("not witnessed by admin") + } + + key = makeRecordKey(domain, name, rt) + if err := ic.DAO.DeleteStorageItem(n.ContractID, key); err != nil { + panic(err) + } + return stackitem.Null{} +} + +func (n *NameService) resolve(ic *interop.Context, args []stackitem.Item) stackitem.Item { + name := toString(args[0]) + rt := toRecordType(args[1]) + result, ok := n.resolveInternal(ic, name, rt, 2) + if !ok { + return stackitem.Null{} + } + return stackitem.NewByteArray([]byte(result)) +} + +func (n *NameService) resolveInternal(ic *interop.Context, name string, t RecordType, redirect int) (string, bool) { + if redirect < 0 { + panic("invalid redirect") + } + records := n.getRecordsInternal(ic.DAO, name) + if data, ok := records[t]; ok { + return data, true + } + data, ok := records[RecordTypeCNAME] + if !ok { + return "", false + } + return n.resolveInternal(ic, data, t, redirect-1) +} + +func (n *NameService) getRecordsInternal(d dao.DAO, name string) map[RecordType]string { + domain := toDomain(name) + key := makeRecordKey(domain, name, 0) + key = key[:len(key)-1] + res := make(map[RecordType]string) + d.Seek(n.ContractID, key, func(k, v []byte) { + rt := RecordType(k[len(k)-1]) + var si state.StorageItem + r := io.NewBinReaderFromBuf(v) + si.DecodeBinary(r) + if r.Err != nil { + panic(r.Err) + } + res[rt] = string(si.Value) + }) + return res +} + +func makeRecordKey(domain, name string, rt RecordType) []byte { + key := make([]byte, 1+util.Uint160Size+util.Uint160Size+1) + key[0] = prefixRecord + i := 1 + i += copy(key[i:], hash.Hash160([]byte(domain)).BytesBE()) + i += copy(key[i:], hash.Hash160([]byte(name)).BytesBE()) + key[i] = byte(rt) + return key +} + +func makeExpirationKey(expiration uint32, tokenID []byte) []byte { + key := make([]byte, 1+4+util.Uint160Size) + key[0] = prefixExpiration + binary.BigEndian.PutUint32(key[1:], expiration) + copy(key[5:], hash.Hash160(tokenID).BytesBE()) + return key +} + +// ToMap implements nftTokenState interface. +func (s *nameState) ToMap() *stackitem.Map { + m := s.NFTTokenState.ToMap() + m.Add(stackitem.NewByteArray([]byte("expiration")), + stackitem.NewBigInteger(new(big.Int).SetUint64(uint64(s.Expiration)))) + return m +} + +// EncodeBinary implements io.Serializable. +func (s *nameState) EncodeBinary(w *io.BinWriter) { + stackitem.EncodeBinaryStackItem(s.ToStackItem(), w) +} + +// DecodeBinary implements io.Serializable. +func (s *nameState) DecodeBinary(r *io.BinReader) { + item := stackitem.DecodeBinaryStackItem(r) + if r.Err == nil { + s.FromStackItem(item) + } +} + +// ToStackItem implements nftTokenState interface. +func (s *nameState) ToStackItem() stackitem.Item { + item := s.NFTTokenState.ToStackItem().(*stackitem.Struct) + exp := new(big.Int).SetUint64(uint64(s.Expiration)) + item.Append(stackitem.NewBigInteger(exp)) + if s.HasAdmin { + item.Append(stackitem.NewByteArray(s.Admin.BytesBE())) + } else { + item.Append(stackitem.Null{}) + } + return item +} + +// FromStackItem implements nftTokenState interface. +func (s *nameState) FromStackItem(item stackitem.Item) error { + err := s.NFTTokenState.FromStackItem(item) + if err != nil { + return err + } + elems := item.Value().([]stackitem.Item) + if len(elems) < 5 { + return errors.New("invalid stack item") + } + bi, err := elems[3].TryInteger() + if err != nil || !bi.IsUint64() { + return errors.New("invalid stack item") + } + + _, isNull := elems[4].(stackitem.Null) + if !isNull { + bs, err := elems[4].TryBytes() + if err != nil { + return err + } + u, err := util.Uint160DecodeBytesBE(bs) + if err != nil { + return err + } + s.Admin = u + } + s.Expiration = uint32(bi.Uint64()) + s.HasAdmin = !isNull + return nil +} + +// Helpers + +func domainFromString(name string) (string, bool) { + i := strings.LastIndexAny(name, ".") + if i < 0 { + return "", false + } + i = strings.LastIndexAny(name[:i], ".") + if i < 0 { + return name, true + } + return name[i+1:], true + +} + +func toDomain(name string) string { + domain, ok := domainFromString(name) + if !ok { + panic("invalid record") + } + return domain +} + +func toRecordType(item stackitem.Item) RecordType { + bi, err := item.TryInteger() + if err != nil || !bi.IsInt64() { + panic("invalid record type") + } + val := bi.Uint64() + if val > math.MaxUint8 { + panic("invalid record type") + } + switch rt := RecordType(val); rt { + case RecordTypeA, RecordTypeCNAME, RecordTypeTXT, RecordTypeAAAA: + return rt + default: + panic("invalid record type") + } +} + +func toName(item stackitem.Item) string { + name := toString(item) + if !matchName(name) { + panic("invalid name") + } + return name +} + +type stringList []string + +// ToStackItem converts sl to stack item. +func (sl stringList) ToStackItem() stackitem.Item { + arr := make([]stackitem.Item, len(sl)) + for i := range sl { + arr[i] = stackitem.NewByteArray([]byte(sl[i])) + } + return stackitem.NewArray(arr) +} + +// FromStackItem converts stack item to string list. +func (sl *stringList) FromStackItem(item stackitem.Item) error { + arr, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("invalid stack item") + } + res := make([]string, len(arr)) + for i := range res { + s, err := stackitem.ToString(arr[i]) + if err != nil { + return err + } + res[i] = s + } + *sl = res + return nil +} + +// EncodeBinary implements io.Serializable. +func (sl stringList) EncodeBinary(w *io.BinWriter) { + stackitem.EncodeBinaryStackItem(sl.ToStackItem(), w) +} + +// DecodeBinary implements io.Serializable. +func (sl *stringList) DecodeBinary(r *io.BinReader) { + item := stackitem.DecodeBinaryStackItem(r) + if r.Err == nil { + sl.FromStackItem(item) + } +} + +func (sl stringList) index(s string) (int, bool) { + index := sort.Search(len(sl), func(i int) bool { + return sl[i] >= s + }) + return index, index < len(sl) && sl[index] == s +} + +func (sl *stringList) remove(s string) bool { + index, has := sl.index(s) + if !has { + return false + } + + copy((*sl)[index:], (*sl)[index+1:]) + *sl = (*sl)[:len(*sl)-1] + return true +} + +func (sl *stringList) add(s string) bool { + index, has := sl.index(s) + if has { + return false + } + + *sl = append(*sl, "") + copy((*sl)[index+1:], (*sl)[index:]) + (*sl)[index] = s + return true +} diff --git a/pkg/core/native/name_service_test.go b/pkg/core/native/name_service_test.go new file mode 100644 index 000000000..ec456a52e --- /dev/null +++ b/pkg/core/native/name_service_test.go @@ -0,0 +1,32 @@ +package native + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// The specification is following C# code: +// string domain = string.Join('.', name.Split('.')[^2..]); +func TestParseDomain(t *testing.T) { + testCases := []struct { + name string + domain string + }{ + {"sim.pl.e", "pl.e"}, + {"some.long.d.o.m.a.i.n", "i.n"}, + {"t.wo", "t.wo"}, + {".dot", ".dot"}, + {".d.ot", "d.ot"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dom, ok := domainFromString(tc.name) + require.True(t, ok) + require.Equal(t, tc.domain, dom) + }) + } + + _, ok := domainFromString("nodots") + require.False(t, ok) +} diff --git a/pkg/core/native/nativenames/names.go b/pkg/core/native/nativenames/names.go index 96dd19f0f..eb007cbbc 100644 --- a/pkg/core/native/nativenames/names.go +++ b/pkg/core/native/nativenames/names.go @@ -9,4 +9,5 @@ const ( Oracle = "OracleContract" Designation = "RoleManagement" Notary = "Notary" + NameService = "NameService" ) diff --git a/pkg/core/native/nonfungible.go b/pkg/core/native/nonfungible.go new file mode 100644 index 000000000..36826506f --- /dev/null +++ b/pkg/core/native/nonfungible.go @@ -0,0 +1,357 @@ +package native + +import ( + "bytes" + "errors" + "math/big" + "sort" + + "github.com/nspcc-dev/neo-go/pkg/core/dao" + "github.com/nspcc-dev/neo-go/pkg/core/interop" + "github.com/nspcc-dev/neo-go/pkg/core/interop/runtime" + istorage "github.com/nspcc-dev/neo-go/pkg/core/interop/storage" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/core/storage" + "github.com/nspcc-dev/neo-go/pkg/encoding/bigint" + "github.com/nspcc-dev/neo-go/pkg/io" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" +) + +type nonfungible struct { + interop.ContractMD + + tokenSymbol string + tokenDecimals byte + + onTransferred func(nftTokenState) + getTokenKey func([]byte) []byte + newTokenState func() nftTokenState +} + +type nftTokenState interface { + io.Serializable + ToStackItem() stackitem.Item + FromStackItem(stackitem.Item) error + ToMap() *stackitem.Map + ID() []byte + Base() *state.NFTTokenState +} + +const ( + prefixNFTTotalSupply = 11 + prefixNFTAccount = 7 + prefixNFTToken = 5 +) + +var ( + nftTotalSupplyKey = []byte{prefixNFTTotalSupply} + + intOne = big.NewInt(1) +) + +func newNonFungible(name string, id int32, symbol string, decimals byte) *nonfungible { + n := &nonfungible{ + ContractMD: *interop.NewContractMD(name, id), + + tokenSymbol: symbol, + tokenDecimals: decimals, + + getTokenKey: func(tokenID []byte) []byte { + return append([]byte{prefixNFTToken}, tokenID...) + }, + newTokenState: func() nftTokenState { + return new(state.NFTTokenState) + }, + } + + desc := newDescriptor("symbol", smartcontract.StringType) + md := newMethodAndPrice(n.symbol, 0, callflag.NoneFlag) + n.AddMethod(md, desc) + + desc = newDescriptor("decimals", smartcontract.IntegerType) + md = newMethodAndPrice(n.decimals, 0, callflag.NoneFlag) + n.AddMethod(md, desc) + + desc = newDescriptor("totalSupply", smartcontract.IntegerType) + md = newMethodAndPrice(n.totalSupply, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("ownerOf", smartcontract.Hash160Type, + manifest.NewParameter("tokenId", smartcontract.ByteArrayType)) + md = newMethodAndPrice(n.OwnerOf, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("balanceOf", smartcontract.IntegerType, + manifest.NewParameter("owner", smartcontract.Hash160Type)) + md = newMethodAndPrice(n.BalanceOf, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("properties", smartcontract.MapType, + manifest.NewParameter("tokenId", smartcontract.ByteArrayType)) + md = newMethodAndPrice(n.Properties, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("tokens", smartcontract.InteropInterfaceType) + md = newMethodAndPrice(n.tokens, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("tokensOf", smartcontract.InteropInterfaceType, + manifest.NewParameter("owner", smartcontract.Hash160Type)) + md = newMethodAndPrice(n.tokensOf, 1000000, callflag.ReadStates) + n.AddMethod(md, desc) + + desc = newDescriptor("transfer", smartcontract.BoolType, + manifest.NewParameter("to", smartcontract.Hash160Type), + manifest.NewParameter("tokenId", smartcontract.ByteArrayType)) + md = newMethodAndPrice(n.transfer, 9000000, callflag.WriteStates|callflag.AllowNotify) + n.AddMethod(md, desc) + + n.AddEvent("Transfer", + manifest.NewParameter("from", smartcontract.Hash160Type), + manifest.NewParameter("to", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType), + manifest.NewParameter("tokenId", smartcontract.ByteArrayType)) + + return n +} + +func (n *nonfungible) symbol(_ *interop.Context, _ []stackitem.Item) stackitem.Item { + return stackitem.NewByteArray([]byte(n.tokenSymbol)) +} + +func (n *nonfungible) decimals(_ *interop.Context, _ []stackitem.Item) stackitem.Item { + return stackitem.NewBigInteger(big.NewInt(int64(n.tokenDecimals))) +} + +func (n *nonfungible) totalSupply(ic *interop.Context, _ []stackitem.Item) stackitem.Item { + return stackitem.NewBigInteger(n.TotalSupply(ic.DAO)) +} + +func (n *nonfungible) TotalSupply(d dao.DAO) *big.Int { + si := d.GetStorageItem(n.ContractID, nftTotalSupplyKey) + if si == nil { + return big.NewInt(0) + } + return bigint.FromBytes(si.Value) +} + +func (n *nonfungible) setTotalSupply(d dao.DAO, ts *big.Int) { + si := &state.StorageItem{Value: bigint.ToBytes(ts)} + err := d.PutStorageItem(n.ContractID, nftTotalSupplyKey, si) + if err != nil { + panic(err) + } +} + +func (n *nonfungible) tokenState(d dao.DAO, tokenID []byte) (nftTokenState, []byte, error) { + key := n.getTokenKey(tokenID) + s := n.newTokenState() + err := getSerializableFromDAO(n.ContractID, d, key, s) + return s, key, err +} + +func (n *nonfungible) accountState(d dao.DAO, owner util.Uint160) (*state.NFTAccountState, []byte, error) { + acc := new(state.NFTAccountState) + keyAcc := makeNFTAccountKey(owner) + err := getSerializableFromDAO(n.ContractID, d, keyAcc, acc) + return acc, keyAcc, err +} + +func (n *nonfungible) putAccountState(d dao.DAO, key []byte, acc *state.NFTAccountState) { + var err error + if acc.Balance.Sign() == 0 { + err = d.DeleteStorageItem(n.ContractID, key) + } else { + err = putSerializableToDAO(n.ContractID, d, key, acc) + } + if err != nil { + panic(err) + } +} + +func (n *nonfungible) OwnerOf(ic *interop.Context, args []stackitem.Item) stackitem.Item { + tokenID, err := args[0].TryBytes() + if err != nil { + panic(err) + } + s, _, err := n.tokenState(ic.DAO, tokenID) + if err != nil { + panic(err) + } + return stackitem.NewByteArray(s.Base().Owner.BytesBE()) +} + +func (n *nonfungible) Properties(ic *interop.Context, args []stackitem.Item) stackitem.Item { + tokenID, err := args[0].TryBytes() + if err != nil { + panic(err) + } + s, _, err := n.tokenState(ic.DAO, tokenID) + if err != nil { + panic(err) + } + return s.ToMap() +} + +func (n *nonfungible) BalanceOf(ic *interop.Context, args []stackitem.Item) stackitem.Item { + owner := toUint160(args[0]) + s, _, err := n.accountState(ic.DAO, owner) + if err != nil { + if errors.Is(err, storage.ErrKeyNotFound) { + return stackitem.NewBigInteger(big.NewInt(0)) + } + panic(err) + } + return stackitem.NewBigInteger(&s.Balance) +} + +func (n *nonfungible) tokens(ic *interop.Context, args []stackitem.Item) stackitem.Item { + prefix := []byte{prefixNFTToken} + siMap, err := ic.DAO.GetStorageItemsWithPrefix(n.ContractID, prefix) + if err != nil { + panic(err) + } + filteredMap := stackitem.NewMap() + for k, v := range siMap { + filteredMap.Add(stackitem.NewByteArray(append(prefix, []byte(k)...)), stackitem.NewByteArray(v.Value)) + } + sort.Slice(filteredMap.Value().([]stackitem.MapElement), func(i, j int) bool { + return bytes.Compare(filteredMap.Value().([]stackitem.MapElement)[i].Key.Value().([]byte), + filteredMap.Value().([]stackitem.MapElement)[j].Key.Value().([]byte)) == -1 + }) + iter := istorage.NewIterator(filteredMap, istorage.FindValuesOnly|istorage.FindDeserialize|istorage.FindPick1) + return stackitem.NewInterop(iter) +} + +func (n *nonfungible) tokensOf(ic *interop.Context, args []stackitem.Item) stackitem.Item { + owner := toUint160(args[0]) + s, _, err := n.accountState(ic.DAO, owner) + if err != nil { + panic(err) + } + arr := make([]stackitem.Item, len(s.Tokens)) + for i := range arr { + arr[i] = stackitem.NewByteArray(s.Tokens[i]) + } + iter, _ := vm.NewIterator(stackitem.NewArray(arr)) + return iter +} + +func (n *nonfungible) mint(ic *interop.Context, s nftTokenState) { + key := n.getTokenKey(s.ID()) + if ic.DAO.GetStorageItem(n.ContractID, key) != nil { + panic("token is already minted") + } + if err := putSerializableToDAO(n.ContractID, ic.DAO, key, s); err != nil { + panic(err) + } + + owner := s.Base().Owner + acc, keyAcc, err := n.accountState(ic.DAO, owner) + if err != nil && !errors.Is(err, storage.ErrKeyNotFound) { + panic(err) + } + acc.Add(s.ID()) + n.putAccountState(ic.DAO, keyAcc, acc) + + ts := n.TotalSupply(ic.DAO) + ts.Add(ts, intOne) + n.setTotalSupply(ic.DAO, ts) + n.postTransfer(ic, nil, &owner, s.ID()) +} + +func (n *nonfungible) postTransfer(ic *interop.Context, from, to *util.Uint160, tokenID []byte) { + ne := state.NotificationEvent{ + ScriptHash: n.Hash, + Name: "Transfer", + Item: stackitem.NewArray([]stackitem.Item{ + addrToStackItem(from), + addrToStackItem(to), + stackitem.NewBigInteger(intOne), + stackitem.NewByteArray(tokenID), + }), + } + ic.Notifications = append(ic.Notifications, ne) +} + +func (n *nonfungible) burn(ic *interop.Context, tokenID []byte) { + key := n.getTokenKey(tokenID) + n.burnByKey(ic, key) +} + +func (n *nonfungible) burnByKey(ic *interop.Context, key []byte) { + token := n.newTokenState() + err := getSerializableFromDAO(n.ContractID, ic.DAO, key, token) + if err != nil { + panic(err) + } + if err := ic.DAO.DeleteStorageItem(n.ContractID, key); err != nil { + panic(err) + } + + owner := token.Base().Owner + acc, keyAcc, err := n.accountState(ic.DAO, owner) + if err != nil { + panic(err) + } + + id := token.ID() + acc.Remove(id) + n.putAccountState(ic.DAO, keyAcc, acc) + + ts := n.TotalSupply(ic.DAO) + ts.Sub(ts, intOne) + n.setTotalSupply(ic.DAO, ts) + n.postTransfer(ic, &owner, nil, id) +} + +func (n *nonfungible) transfer(ic *interop.Context, args []stackitem.Item) stackitem.Item { + to := toUint160(args[0]) + tokenID, err := args[1].TryBytes() + if err != nil { + panic(err) + } + + token, tokenKey, err := n.tokenState(ic.DAO, tokenID) + if err != nil { + panic(err) + } + + from := token.Base().Owner + ok, err := runtime.CheckHashedWitness(ic, from) + if err != nil || !ok { + return stackitem.NewBool(false) + } + if from != to { + acc, key, err := n.accountState(ic.DAO, from) + if err != nil { + panic(err) + } + acc.Remove(tokenID) + n.putAccountState(ic.DAO, key, acc) + + token.Base().Owner = to + n.onTransferred(token) + err = putSerializableToDAO(n.ContractID, ic.DAO, tokenKey, token) + if err != nil { + panic(err) + } + acc, key, err = n.accountState(ic.DAO, to) + if err != nil && !errors.Is(err, storage.ErrKeyNotFound) { + panic(err) + } + acc.Add(tokenID) + n.putAccountState(ic.DAO, key, acc) + } + n.postTransfer(ic, &from, &to, tokenID) + return stackitem.NewBool(true) +} + +func makeNFTAccountKey(owner util.Uint160) []byte { + return append([]byte{prefixNFTAccount}, owner.BytesBE()...) +} diff --git a/pkg/core/native/util.go b/pkg/core/native/util.go index fcd74c08b..929b3d1e3 100644 --- a/pkg/core/native/util.go +++ b/pkg/core/native/util.go @@ -8,6 +8,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/storage" "github.com/nspcc-dev/neo-go/pkg/io" "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" ) func getSerializableFromDAO(id int32, d dao.DAO, key []byte, item io.Serializable) error { @@ -70,3 +71,11 @@ func makeUint160Key(prefix byte, h util.Uint160) []byte { copy(k[1:], h.BytesBE()) return k } + +func toString(item stackitem.Item) string { + s, err := stackitem.ToString(item) + if err != nil { + panic(err) + } + return s +} diff --git a/pkg/core/native_name_service_test.go b/pkg/core/native_name_service_test.go new file mode 100644 index 000000000..2042ee55a --- /dev/null +++ b/pkg/core/native_name_service_test.go @@ -0,0 +1,406 @@ +package core + +import ( + "testing" + + "github.com/nspcc-dev/neo-go/internal/testchain" + "github.com/nspcc-dev/neo-go/pkg/core/block" + "github.com/nspcc-dev/neo-go/pkg/core/interop/interopnames" + "github.com/nspcc-dev/neo-go/pkg/core/native" + "github.com/nspcc-dev/neo-go/pkg/core/transaction" + "github.com/nspcc-dev/neo-go/pkg/io" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/emit" + "github.com/nspcc-dev/neo-go/pkg/vm/opcode" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/nspcc-dev/neo-go/pkg/wallet" + "github.com/stretchr/testify/require" +) + +func TestNameService_Price(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + testGetSet(t, bc, bc.contracts.NameService.Hash, "Price", + native.DefaultDomainPrice, 1, 10000_00000000) +} + +func TestNonfungible(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + acc := newAccountWithGAS(t, bc) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, "symbol", "NNS") + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, "decimals", 0) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, "totalSupply", 0) +} + +func TestAddRoot(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + transferFundsToCommittee(t, bc) + nsHash := bc.contracts.NameService.Hash + + t.Run("invalid format", func(t *testing.T) { + testNameServiceInvoke(t, bc, "addRoot", nil, "") + }) + t.Run("not signed by committee", func(t *testing.T) { + aer, err := invokeContractMethod(bc, 1000_0000, nsHash, "addRoot", "some") + require.NoError(t, err) + checkFAULTState(t, aer) + }) + + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "some") + t.Run("already exists", func(t *testing.T) { + testNameServiceInvoke(t, bc, "addRoot", nil, "some") + }) +} + +func TestExpiration(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + transferFundsToCommittee(t, bc) + acc := newAccountWithGAS(t, bc) + + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "com") + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, acc, "register", + true, "first.com", acc.Contract.ScriptHash()) + + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, + "setRecord", stackitem.Null{}, "first.com", int64(native.RecordTypeTXT), "sometext") + b1 := bc.topBlock.Load().(*block.Block) + + tx, err := prepareContractMethodInvokeGeneric(bc, defaultRegisterSysfee, bc.contracts.NameService.Hash, + "register", acc, "second.com", acc.Contract.ScriptHash()) + require.NoError(t, err) + b2 := newBlockCustom(bc.GetConfig(), func(b *block.Block) { + b.Index = b1.Index + 1 + b.PrevHash = b1.Hash() + b.Timestamp = b1.Timestamp + 10000 + }, tx) + require.NoError(t, bc.AddBlock(b2)) + checkTxHalt(t, bc, tx.Hash()) + + tx, err = prepareContractMethodInvokeGeneric(bc, defaultNameServiceSysfee, bc.contracts.NameService.Hash, + "isAvailable", acc, "first.com") + require.NoError(t, err) + b3 := newBlockCustom(bc.GetConfig(), func(b *block.Block) { + b.Index = b2.Index + 1 + b.PrevHash = b2.Hash() + b.Timestamp = b1.Timestamp + (secondsInYear+1)*1000 + }, tx) + require.NoError(t, bc.AddBlock(b3)) + aer, err := bc.GetAppExecResults(tx.Hash(), trigger.Application) + require.NoError(t, err) + checkResult(t, &aer[0], stackitem.NewBool(true)) + + tx, err = prepareContractMethodInvokeGeneric(bc, defaultNameServiceSysfee, bc.contracts.NameService.Hash, + "isAvailable", acc, "second.com") + require.NoError(t, err) + b4 := newBlockCustom(bc.GetConfig(), func(b *block.Block) { + b.Index = b3.Index + 1 + b.PrevHash = b3.Hash() + b.Timestamp = b3.Timestamp + 1000 + }, tx) + require.NoError(t, bc.AddBlock(b4)) + aer, err = bc.GetAppExecResults(tx.Hash(), trigger.Application) + require.NoError(t, err) + checkResult(t, &aer[0], stackitem.NewBool(false)) + + tx, err = prepareContractMethodInvokeGeneric(bc, defaultNameServiceSysfee, bc.contracts.NameService.Hash, + "getRecord", acc, "first.com", int64(native.RecordTypeTXT)) + require.NoError(t, err) + b5 := newBlockCustom(bc.GetConfig(), func(b *block.Block) { + b.Index = b4.Index + 1 + b.PrevHash = b4.Hash() + b.Timestamp = b4.Timestamp + 1000 + }, tx) + require.NoError(t, bc.AddBlock(b5)) + aer, err = bc.GetAppExecResults(tx.Hash(), trigger.Application) + require.NoError(t, err) + checkResult(t, &aer[0], stackitem.Null{}) + +} + +const secondsInYear = 365 * 24 * 3600 + +func TestRegisterAndRenew(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + transferFundsToCommittee(t, bc) + + testNameServiceInvoke(t, bc, "isAvailable", nil, "neo.com") + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "org") + testNameServiceInvoke(t, bc, "isAvailable", nil, "neo.com") + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "com") + testNameServiceInvoke(t, bc, "isAvailable", true, "neo.com") + testNameServiceInvoke(t, bc, "register", nil, "neo.org", testchain.CommitteeScriptHash()) + testNameServiceInvoke(t, bc, "register", nil, "docs.neo.org", testchain.CommitteeScriptHash()) + testNameServiceInvoke(t, bc, "register", nil, "\nneo.com'", testchain.CommitteeScriptHash()) + testNameServiceInvoke(t, bc, "register", nil, "neo.com\n", testchain.CommitteeScriptHash()) + testNameServiceInvoke(t, bc, "register", nil, "neo.com", testchain.CommitteeScriptHash()) + testNameServiceInvokeAux(t, bc, native.DefaultDomainPrice, true, "register", + nil, "neo.com", testchain.CommitteeScriptHash()) + + testNameServiceInvoke(t, bc, "isAvailable", true, "neo.com") + testNameServiceInvoke(t, bc, "balanceOf", 0, testchain.CommitteeScriptHash()) + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, true, "register", + true, "neo.com", testchain.CommitteeScriptHash()) + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, true, "register", + false, "neo.com", testchain.CommitteeScriptHash()) + testNameServiceInvoke(t, bc, "isAvailable", false, "neo.com") + + topBlock := bc.topBlock.Load().(*block.Block) + expectedExpiration := topBlock.Timestamp/1000 + secondsInYear + + props := stackitem.NewMap() + props.Add(stackitem.Make("name"), stackitem.Make("neo.com")) + props.Add(stackitem.Make("description"), stackitem.Make("")) + props.Add(stackitem.Make("expiration"), stackitem.Make(expectedExpiration)) + testNameServiceInvoke(t, bc, "properties", props, "neo.com") + testNameServiceInvoke(t, bc, "balanceOf", 1, testchain.CommitteeScriptHash()) + testNameServiceInvoke(t, bc, "ownerOf", testchain.CommitteeScriptHash().BytesBE(), []byte("neo.com")) + + t.Run("invalid token ID", func(t *testing.T) { + testNameServiceInvoke(t, bc, "properties", nil, "not.exists") + testNameServiceInvoke(t, bc, "ownerOf", nil, "not.exists") + testNameServiceInvoke(t, bc, "properties", nil, []interface{}{}) + testNameServiceInvoke(t, bc, "ownerOf", nil, []interface{}{}) + }) + + // Renew + expectedExpiration += secondsInYear + testNameServiceInvokeAux(t, bc, 100_0000_0000, true, "renew", expectedExpiration, "neo.com") + + props.Add(stackitem.Make("expiration"), stackitem.Make(expectedExpiration)) + testNameServiceInvoke(t, bc, "properties", props, "neo.com") +} + +func TestSetGetRecord(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + transferFundsToCommittee(t, bc) + acc := newAccountWithGAS(t, bc) + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "com") + + t.Run("set before register", func(t *testing.T) { + testNameServiceInvoke(t, bc, "setRecord", nil, "neo.com", int64(native.RecordTypeTXT), "sometext") + }) + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, true, "register", + true, "neo.com", testchain.CommitteeScriptHash()) + t.Run("invalid parameters", func(t *testing.T) { + testNameServiceInvoke(t, bc, "setRecord", nil, "neo.com", int64(0xFF), "1.2.3.4") + testNameServiceInvoke(t, bc, "setRecord", nil, "neo.com", int64(native.RecordTypeA), "not.an.ip.address") + }) + t.Run("invalid witness", func(t *testing.T) { + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, "setRecord", nil, + "neo.com", int64(native.RecordTypeA), "1.2.3.4") + }) + testNameServiceInvoke(t, bc, "getRecord", stackitem.Null{}, "neo.com", int64(native.RecordTypeA)) + testNameServiceInvoke(t, bc, "setRecord", stackitem.Null{}, "neo.com", int64(native.RecordTypeA), "1.2.3.4") + testNameServiceInvoke(t, bc, "getRecord", "1.2.3.4", "neo.com", int64(native.RecordTypeA)) + testNameServiceInvoke(t, bc, "setRecord", stackitem.Null{}, "neo.com", int64(native.RecordTypeA), "1.2.3.4") + testNameServiceInvoke(t, bc, "getRecord", "1.2.3.4", "neo.com", int64(native.RecordTypeA)) + testNameServiceInvoke(t, bc, "setRecord", stackitem.Null{}, "neo.com", int64(native.RecordTypeAAAA), "2001:0000:1F1F:0000:0000:0100:11A0:ADDF") + testNameServiceInvoke(t, bc, "setRecord", stackitem.Null{}, "neo.com", int64(native.RecordTypeCNAME), "nspcc.ru") + testNameServiceInvoke(t, bc, "setRecord", stackitem.Null{}, "neo.com", int64(native.RecordTypeTXT), "sometext") + + // Delete record. + t.Run("invalid witness", func(t *testing.T) { + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, "setRecord", nil, + "neo.com", int64(native.RecordTypeCNAME)) + }) + testNameServiceInvoke(t, bc, "getRecord", "nspcc.ru", "neo.com", int64(native.RecordTypeCNAME)) + testNameServiceInvoke(t, bc, "deleteRecord", stackitem.Null{}, "neo.com", int64(native.RecordTypeCNAME)) + testNameServiceInvoke(t, bc, "getRecord", stackitem.Null{}, "neo.com", int64(native.RecordTypeCNAME)) + testNameServiceInvoke(t, bc, "getRecord", "1.2.3.4", "neo.com", int64(native.RecordTypeA)) +} + +func TestSetAdmin(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + transferFundsToCommittee(t, bc) + owner := newAccountWithGAS(t, bc) + admin := newAccountWithGAS(t, bc) + guest := newAccountWithGAS(t, bc) + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "com") + + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, owner, "register", true, + "neo.com", owner.PrivateKey().GetScriptHash()) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, guest, "setAdmin", nil, + "neo.com", admin.PrivateKey().GetScriptHash()) + + // Must be witnessed by both owner and admin. + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, owner, "setAdmin", nil, + "neo.com", admin.PrivateKey().GetScriptHash()) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, admin, "setAdmin", nil, + "neo.com", admin.PrivateKey().GetScriptHash()) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, []*wallet.Account{owner, admin}, + "setAdmin", stackitem.Null{}, + "neo.com", admin.PrivateKey().GetScriptHash()) + + t.Run("set and delete by admin", func(t *testing.T) { + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, admin, "setRecord", stackitem.Null{}, + "neo.com", int64(native.RecordTypeTXT), "sometext") + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, guest, "deleteRecord", nil, + "neo.com", int64(native.RecordTypeTXT)) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, admin, "deleteRecord", stackitem.Null{}, + "neo.com", int64(native.RecordTypeTXT)) + }) + + t.Run("set admin to null", func(t *testing.T) { + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, admin, "setRecord", stackitem.Null{}, + "neo.com", int64(native.RecordTypeTXT), "sometext") + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, owner, "setAdmin", stackitem.Null{}, + "neo.com", nil) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, admin, "deleteRecord", nil, + "neo.com", int64(native.RecordTypeTXT)) + }) +} + +func TestTransfer(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + transferFundsToCommittee(t, bc) + from := newAccountWithGAS(t, bc) + to := newAccountWithGAS(t, bc) + + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "com") + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, from, "register", + true, "neo.com", from.PrivateKey().GetScriptHash()) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, from, "setRecord", stackitem.Null{}, + "neo.com", int64(native.RecordTypeA), "1.2.3.4") + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, from, "transfer", + nil, to.Contract.ScriptHash().BytesBE(), []byte("not.exists")) + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, true, "transfer", + false, to.Contract.ScriptHash().BytesBE(), []byte("neo.com")) + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, from, "transfer", + true, to.Contract.ScriptHash().BytesBE(), []byte("neo.com")) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, from, "totalSupply", 1) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, from, "ownerOf", + to.Contract.ScriptHash().BytesBE(), []byte("neo.com")) +} + +func TestTokensOf(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + transferFundsToCommittee(t, bc) + acc1 := newAccountWithGAS(t, bc) + acc2 := newAccountWithGAS(t, bc) + + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "com") + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, acc1, "register", + true, "neo.com", acc1.PrivateKey().GetScriptHash()) + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, acc2, "register", + true, "nspcc.com", acc2.PrivateKey().GetScriptHash()) + + testTokensOf(t, bc, acc1, [][]byte{[]byte("neo.com")}, acc1.Contract.ScriptHash().BytesBE()) + testTokensOf(t, bc, acc1, [][]byte{[]byte("nspcc.com")}, acc2.Contract.ScriptHash().BytesBE()) + testTokensOf(t, bc, acc1, [][]byte{[]byte("neo.com"), []byte("nspcc.com")}) + testTokensOf(t, bc, acc1, nil, util.Uint160{}.BytesBE()) +} + +func testTokensOf(t *testing.T, bc *Blockchain, signer *wallet.Account, result [][]byte, args ...interface{}) { + method := "tokensOf" + if len(args) == 0 { + method = "tokens" + } + w := io.NewBufBinWriter() + emit.AppCall(w.BinWriter, bc.contracts.NameService.Hash, method, callflag.All, args...) + for range result { + emit.Opcodes(w.BinWriter, opcode.DUP) + emit.Syscall(w.BinWriter, interopnames.SystemIteratorNext) + emit.Opcodes(w.BinWriter, opcode.ASSERT) + + emit.Opcodes(w.BinWriter, opcode.DUP) + emit.Syscall(w.BinWriter, interopnames.SystemIteratorValue) + emit.Opcodes(w.BinWriter, opcode.SWAP) + } + emit.Opcodes(w.BinWriter, opcode.DROP) + emit.Int(w.BinWriter, int64(len(result))) + emit.Opcodes(w.BinWriter, opcode.PACK) + require.NoError(t, w.Err) + script := w.Bytes() + tx := transaction.New(bc.GetConfig().Magic, script, defaultNameServiceSysfee) + tx.ValidUntilBlock = bc.BlockHeight() + 1 + signTxWithAccounts(bc, tx, signer) + aers, err := persistBlock(bc, tx) + require.NoError(t, err) + if result == nil { + checkFAULTState(t, aers[0]) + return + } + arr := make([]stackitem.Item, 0, len(result)) + for i := len(result) - 1; i >= 0; i-- { + arr = append(arr, stackitem.Make(result[i])) + } + checkResult(t, aers[0], stackitem.NewArray(arr)) +} + +func TestResolve(t *testing.T) { + bc := newTestChain(t) + defer bc.Close() + + transferFundsToCommittee(t, bc) + acc := newAccountWithGAS(t, bc) + + testNameServiceInvoke(t, bc, "addRoot", stackitem.Null{}, "com") + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, acc, "register", + true, "neo.com", acc.PrivateKey().GetScriptHash()) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, "setRecord", stackitem.Null{}, + "neo.com", int64(native.RecordTypeA), "1.2.3.4") + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, "setRecord", stackitem.Null{}, + "neo.com", int64(native.RecordTypeCNAME), "alias.com") + + testNameServiceInvokeAux(t, bc, defaultRegisterSysfee, acc, "register", + true, "alias.com", acc.PrivateKey().GetScriptHash()) + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, acc, "setRecord", stackitem.Null{}, + "alias.com", int64(native.RecordTypeTXT), "sometxt") + + testNameServiceInvoke(t, bc, "resolve", "1.2.3.4", + "neo.com", int64(native.RecordTypeA)) + testNameServiceInvoke(t, bc, "resolve", "alias.com", + "neo.com", int64(native.RecordTypeCNAME)) + testNameServiceInvoke(t, bc, "resolve", "sometxt", + "neo.com", int64(native.RecordTypeTXT)) + testNameServiceInvoke(t, bc, "resolve", stackitem.Null{}, + "neo.com", int64(native.RecordTypeAAAA)) +} + +const ( + defaultNameServiceSysfee = 4000_0000 + defaultRegisterSysfee = 10_0000_0000 + native.DefaultDomainPrice +) + +func testNameServiceInvoke(t *testing.T, bc *Blockchain, method string, result interface{}, args ...interface{}) { + testNameServiceInvokeAux(t, bc, defaultNameServiceSysfee, true, method, result, args...) +} + +func testNameServiceInvokeAux(t *testing.T, bc *Blockchain, sysfee int64, signer interface{}, method string, result interface{}, args ...interface{}) { + if sysfee < 0 { + sysfee = defaultNameServiceSysfee + } + aer, err := invokeContractMethodGeneric(bc, sysfee, bc.contracts.NameService.Hash, method, signer, args...) + require.NoError(t, err) + if result == nil { + checkFAULTState(t, aer) + return + } + checkResult(t, aer, stackitem.Make(result)) +} + +func newAccountWithGAS(t *testing.T, bc *Blockchain) *wallet.Account { + acc, err := wallet.NewAccount() + require.NoError(t, err) + transferTokenFromMultisigAccount(t, bc, acc.PrivateKey().GetScriptHash(), bc.contracts.GAS.Hash, 1000_00000000) + return acc +} diff --git a/pkg/core/native_policy_test.go b/pkg/core/native_policy_test.go index b8020d037..6745db604 100644 --- a/pkg/core/native_policy_test.go +++ b/pkg/core/native_policy_test.go @@ -17,7 +17,7 @@ import ( func transferFundsToCommittee(t *testing.T, chain *Blockchain) { transferTokenFromMultisigAccount(t, chain, testchain.CommitteeScriptHash(), - chain.contracts.GAS.Hash, 100_00000000) + chain.contracts.GAS.Hash, 1000_00000000) } func testGetSet(t *testing.T, chain *Blockchain, hash util.Uint160, name string, defaultValue, minValue, maxValue int64) { diff --git a/pkg/core/state/nonfungible.go b/pkg/core/state/nonfungible.go new file mode 100644 index 000000000..3f61e26b3 --- /dev/null +++ b/pkg/core/state/nonfungible.go @@ -0,0 +1,185 @@ +package state + +import ( + "bytes" + "errors" + "math/big" + "sort" + + "github.com/nspcc-dev/neo-go/pkg/io" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" +) + +// NFTTokenState represents state of nonfungible token. +type NFTTokenState struct { + Owner util.Uint160 + Name string + Description string +} + +// NFTAccountState represents state of nonfunglible account. +type NFTAccountState struct { + NEP17BalanceState + Tokens [][]byte +} + +// Base returns base class. +func (s *NFTTokenState) Base() *NFTTokenState { + return s +} + +// ToStackItem converts NFTTokenState to stackitem. +func (s *NFTTokenState) ToStackItem() stackitem.Item { + owner := s.Owner + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray(owner.BytesBE()), + stackitem.NewByteArray([]byte(s.Name)), + stackitem.NewByteArray([]byte(s.Description)), + }) +} + +// EncodeBinary implements io.Serializable. +func (s *NFTTokenState) EncodeBinary(w *io.BinWriter) { + stackitem.EncodeBinaryStackItem(s.ToStackItem(), w) +} + +// FromStackItem converts stackitem to NFTTokenState. +func (s *NFTTokenState) FromStackItem(item stackitem.Item) error { + arr, ok := item.Value().([]stackitem.Item) + if !ok || len(arr) < 3 { + return errors.New("invalid stack item") + } + + bs, err := arr[0].TryBytes() + if err != nil { + return err + } + owner, err := util.Uint160DecodeBytesBE(bs) + if err != nil { + return err + } + name, err := stackitem.ToString(arr[1]) + if err != nil { + return err + } + desc, err := stackitem.ToString(arr[2]) + if err != nil { + return err + } + + s.Owner = owner + s.Name = name + s.Description = desc + return nil +} + +// DecodeBinary implements io.Serializable. +func (s *NFTTokenState) DecodeBinary(r *io.BinReader) { + item := stackitem.DecodeBinaryStackItem(r) + if r.Err == nil { + r.Err = s.FromStackItem(item) + } +} + +// ToMap converts NFTTokenState to Map stackitem. +func (s *NFTTokenState) ToMap() *stackitem.Map { + return stackitem.NewMapWithValue([]stackitem.MapElement{ + { + Key: stackitem.NewByteArray([]byte("name")), + Value: stackitem.NewByteArray([]byte(s.Name)), + }, + { + Key: stackitem.NewByteArray([]byte("description")), + Value: stackitem.NewByteArray([]byte(s.Description)), + }, + }) +} + +// ID returns token id. +func (s *NFTTokenState) ID() []byte { + return []byte(s.Name) +} + +// ToStackItem converts NFTAccountState to stackitem. +func (s *NFTAccountState) ToStackItem() stackitem.Item { + st := s.NEP17BalanceState.toStackItem().(*stackitem.Struct) + arr := make([]stackitem.Item, len(s.Tokens)) + for i := range arr { + arr[i] = stackitem.NewByteArray(s.Tokens[i]) + } + st.Append(stackitem.NewArray(arr)) + return st +} + +// FromStackItem converts stackitem to NFTAccountState. +func (s *NFTAccountState) FromStackItem(item stackitem.Item) error { + s.NEP17BalanceState.fromStackItem(item) + arr := item.Value().([]stackitem.Item) + if len(arr) < 2 { + return errors.New("invalid stack item") + } + arr, ok := arr[1].Value().([]stackitem.Item) + if !ok { + return errors.New("invalid stack item") + } + s.Tokens = make([][]byte, len(arr)) + for i := range s.Tokens { + bs, err := arr[i].TryBytes() + if err != nil { + return err + } + s.Tokens[i] = bs + } + return nil +} + +// EncodeBinary implements io.Serializable. +func (s *NFTAccountState) EncodeBinary(w *io.BinWriter) { + stackitem.EncodeBinaryStackItem(s.ToStackItem(), w) +} + +// DecodeBinary implements io.Serializable. +func (s *NFTAccountState) DecodeBinary(r *io.BinReader) { + item := stackitem.DecodeBinaryStackItem(r) + if r.Err == nil { + r.Err = s.FromStackItem(item) + } +} + +func (s *NFTAccountState) index(tokenID []byte) (int, bool) { + lt := len(s.Tokens) + index := sort.Search(lt, func(i int) bool { + return bytes.Compare(s.Tokens[i], tokenID) >= 0 + }) + return index, index < lt && bytes.Equal(s.Tokens[index], tokenID) +} + +// Add adds token id to the set of account tokens +// and returns true on success. +func (s *NFTAccountState) Add(tokenID []byte) bool { + index, isPresent := s.index(tokenID) + if isPresent { + return false + } + + s.Balance.Add(&s.Balance, big.NewInt(1)) + s.Tokens = append(s.Tokens, []byte{}) + copy(s.Tokens[index+1:], s.Tokens[index:]) + s.Tokens[index] = tokenID + return true +} + +// Remove removes token id to the set of account tokens +// and returns true on success. +func (s *NFTAccountState) Remove(tokenID []byte) bool { + index, isPresent := s.index(tokenID) + if !isPresent { + return false + } + + s.Balance.Sub(&s.Balance, big.NewInt(1)) + copy(s.Tokens[index:], s.Tokens[index+1:]) + s.Tokens = s.Tokens[:len(s.Tokens)-1] + return true +} diff --git a/pkg/core/state/nonfungible_test.go b/pkg/core/state/nonfungible_test.go new file mode 100644 index 000000000..3333a8f62 --- /dev/null +++ b/pkg/core/state/nonfungible_test.go @@ -0,0 +1,131 @@ +package state + +import ( + "math/big" + "testing" + + "github.com/nspcc-dev/neo-go/internal/random" + "github.com/nspcc-dev/neo-go/internal/testserdes" + "github.com/nspcc-dev/neo-go/pkg/io" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/stretchr/testify/require" +) + +func newStruct(args ...interface{}) *stackitem.Struct { + arr := make([]stackitem.Item, len(args)) + for i := range args { + arr[i] = stackitem.Make(args[i]) + } + return stackitem.NewStruct(arr) +} + +func TestNFTTokenState_Serializable(t *testing.T) { + t.Run("valid", func(t *testing.T) { + s := &NFTTokenState{ + Owner: random.Uint160(), + Name: "random name", + Description: "random description", + } + id := s.ID() + actual := new(NFTTokenState) + testserdes.EncodeDecodeBinary(t, s, actual) + require.Equal(t, id, actual.ID()) + }) + t.Run("invalid", func(t *testing.T) { + errCases := []struct { + name string + item stackitem.Item + }{ + {"invalid type", stackitem.NewByteArray([]byte{1, 2, 3})}, + {"invalid owner type", + newStruct(stackitem.NewArray(nil), "name", "desc")}, + {"invalid owner uint160", newStruct("123", "name", "desc")}, + {"invalid name", + newStruct(random.Uint160().BytesBE(), []byte{0x80}, "desc")}, + {"invalid description", + newStruct(random.Uint160().BytesBE(), "name", []byte{0x80})}, + } + + for _, tc := range errCases { + t.Run(tc.name, func(t *testing.T) { + w := io.NewBufBinWriter() + stackitem.EncodeBinaryStackItem(tc.item, w.BinWriter) + require.NoError(t, w.Err) + require.Error(t, testserdes.DecodeBinary(w.Bytes(), new(NFTTokenState))) + }) + } + }) +} + +func TestNFTTokenState_ToMap(t *testing.T) { + s := &NFTTokenState{ + Owner: random.Uint160(), + Name: "random name", + Description: "random description", + } + m := s.ToMap() + + elems := m.Value().([]stackitem.MapElement) + i := m.Index(stackitem.Make("name")) + require.True(t, i < len(elems)) + require.Equal(t, []byte("random name"), elems[i].Value.Value()) + + i = m.Index(stackitem.Make("description")) + require.True(t, i < len(elems)) + require.Equal(t, []byte("random description"), elems[i].Value.Value()) +} + +func TestNFTAccountState_Serializable(t *testing.T) { + t.Run("good", func(t *testing.T) { + s := &NFTAccountState{ + NEP17BalanceState: NEP17BalanceState{ + Balance: *big.NewInt(10), + }, + Tokens: [][]byte{ + {1, 2, 3}, + {3}, + }, + } + testserdes.EncodeDecodeBinary(t, s, new(NFTAccountState)) + }) + t.Run("invalid", func(t *testing.T) { + errCases := []struct { + name string + item stackitem.Item + }{ + {"small size", newStruct(42)}, + {"not an array", newStruct(11, stackitem.NewByteArray([]byte{}))}, + {"not an array", + newStruct(11, stackitem.NewArray([]stackitem.Item{ + stackitem.NewArray(nil), + }))}, + } + + for _, tc := range errCases { + t.Run(tc.name, func(t *testing.T) { + w := io.NewBufBinWriter() + stackitem.EncodeBinaryStackItem(tc.item, w.BinWriter) + require.NoError(t, w.Err) + require.Error(t, testserdes.DecodeBinary(w.Bytes(), new(NFTAccountState))) + }) + } + }) +} + +func TestNFTAccountState_AddRemove(t *testing.T) { + var s NFTAccountState + require.True(t, s.Add([]byte{1, 2, 3})) + require.EqualValues(t, 1, s.Balance.Int64()) + require.True(t, s.Add([]byte{1})) + require.EqualValues(t, 2, s.Balance.Int64()) + + require.False(t, s.Add([]byte{1, 2, 3})) + require.EqualValues(t, 2, s.Balance.Int64()) + + require.True(t, s.Remove([]byte{1})) + require.EqualValues(t, 1, s.Balance.Int64()) + require.False(t, s.Remove([]byte{1})) + require.EqualValues(t, 1, s.Balance.Int64()) + require.True(t, s.Remove([]byte{1, 2, 3})) + require.EqualValues(t, 0, s.Balance.Int64()) +} diff --git a/pkg/vm/interop.go b/pkg/vm/interop.go index cd4f65d17..129357886 100644 --- a/pkg/vm/interop.go +++ b/pkg/vm/interop.go @@ -122,27 +122,34 @@ func IteratorValue(v *VM) error { return nil } -// IteratorCreate handles syscall System.Iterator.Create. -func IteratorCreate(v *VM) error { - data := v.Estack().Pop() - var item stackitem.Item - switch t := data.value.(type) { +// NewIterator creates new iterator from the provided stack item. +func NewIterator(item stackitem.Item) (stackitem.Item, error) { + switch t := item.(type) { case *stackitem.Array, *stackitem.Struct: - item = stackitem.NewInterop(&arrayWrapper{ + return stackitem.NewInterop(&arrayWrapper{ index: -1, value: t.Value().([]stackitem.Item), - }) + }), nil case *stackitem.Map: - item = NewMapIterator(t) + return NewMapIterator(t), nil default: data, err := t.TryBytes() if err != nil { - return fmt.Errorf("non-iterable type %s", t.Type()) + return nil, fmt.Errorf("non-iterable type %s", t.Type()) } - item = stackitem.NewInterop(&byteArrayWrapper{ + return stackitem.NewInterop(&byteArrayWrapper{ index: -1, value: data, - }) + }), nil + } +} + +// IteratorCreate handles syscall System.Iterator.Create. +func IteratorCreate(v *VM) error { + data := v.Estack().Pop().Item() + item, err := NewIterator(data) + if err != nil { + return err } v.Estack().Push(&Element{value: item})