diff --git a/pkg/core/native/contract.go b/pkg/core/native/contract.go index cbcc1f3..14e0a93 100644 --- a/pkg/core/native/contract.go +++ b/pkg/core/native/contract.go @@ -119,6 +119,9 @@ type ( GetTokenByIDPublic(d *dao.Simple, tokenID uint64) (*state.PersonToken, error) TokenExists(d *dao.Simple, owner util.Uint160) bool GetAttribute(d *dao.Simple, tokenID uint64, key string) (*state.Attribute, error) + // IsAdultVerified checks if the owner has a verified "age_verified" attribute + // indicating they are 18+ years old. Used for age-restricted purchases. + IsAdultVerified(d *dao.Simple, owner util.Uint160) bool } // IRoleRegistry is an interface required from native RoleRegistry contract @@ -348,6 +351,7 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { vts := newVTS() vts.NEO = neo vts.RoleRegistry = roleRegistry + vts.PersonToken = personToken return []interop.Contract{ mgmt, diff --git a/pkg/core/native/native_test/vts_test.go b/pkg/core/native/native_test/vts_test.go index 2460ff1..cae6a5d 100644 --- a/pkg/core/native/native_test/vts_test.go +++ b/pkg/core/native/native_test/vts_test.go @@ -3,6 +3,7 @@ package native_test import ( "testing" + "github.com/nspcc-dev/neo-go/pkg/crypto/hash" "github.com/stretchr/testify/require" "github.com/tutus-one/tutus-chain/pkg/core/native/nativenames" "github.com/tutus-one/tutus-chain/pkg/core/state" @@ -14,6 +15,56 @@ func newVTSClient(t *testing.T) *neotest.ContractInvoker { return newNativeClient(t, nativenames.VTS) } +// registerVita is a helper to register a PersonToken (Vita) for an account. +// This is required for accounts receiving restricted VTS. +// Uses the existing executor to ensure we're on the same blockchain. +func registerVita(t *testing.T, e *neotest.Executor, acc neotest.Signer) { + personTokenHash := e.NativeHash(t, nativenames.PersonToken) + c := e.NewInvoker(personTokenHash, acc) + owner := acc.ScriptHash() + personHash := hash.Sha256(owner.BytesBE()).BytesBE() + isEntity := false + recoveryHash := hash.Sha256([]byte("recovery")).BytesBE() + + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + _, ok := stack[0].Value().([]byte) + require.True(t, ok, "expected ByteArray result for tokenID") + }, "register", owner.BytesBE(), personHash, isEntity, recoveryHash) +} + +// addAgeVerifiedAttribute adds the "age_verified" attribute to an account's Vita. +// This is required for spending at age-restricted vendors. +// Uses the existing executor to ensure we're on the same blockchain. +func addAgeVerifiedAttribute(t *testing.T, e *neotest.Executor, committee neotest.Signer, acc neotest.Signer) { + personTokenHash := e.NativeHash(t, nativenames.PersonToken) + c := e.CommitteeInvoker(personTokenHash) + owner := acc.ScriptHash() + + // Get the token ID from getToken result + stack, err := c.TestInvoke(t, "getToken", owner.BytesBE()) + require.NoError(t, err) + tokenItem := stack.Pop().Item() + tokenArr := tokenItem.Value().([]stackitem.Item) + tokenIDBI, _ := tokenArr[0].TryInteger() + tokenID := tokenIDBI.Int64() + + // Create hash and encrypted value for the attribute + valueHash := hash.Sha256([]byte("true")).BytesBE() + valueEnc := []byte("true") // For testing, we just use plaintext + + // setAttribute: tokenId, key, valueHash, valueEnc, expiresAt, disclosureLevel + // disclosureLevel: 0 = private, 1 = selective, 2 = public + c.Invoke(t, true, "setAttribute", + tokenID, + "age_verified", + valueHash, + valueEnc, + int64(0), // expiresAt (0 = never expires) + int64(2), // disclosureLevel = public + ) +} + // TestVTS_NEP17Compliance tests basic NEP-17 methods. func TestVTS_NEP17Compliance(t *testing.T) { c := newVTSClient(t) @@ -74,6 +125,9 @@ func TestVTS_MintRestricted(t *testing.T) { acc := e.NewAccount(t) committeeInvoker := c.WithSigners(c.Committee) + // Restricted VTS requires recipient to have a Vita + registerVita(t, e, acc) + t.Run("mint food-restricted VTS", func(t *testing.T) { committeeInvoker.Invoke(t, true, "mintRestricted", acc.ScriptHash(), 500_00000000, state.CategoryFood) }) @@ -175,11 +229,11 @@ func TestVTS_VendorRegistration(t *testing.T) { userInvoker := c.WithSigners(userAcc) t.Run("non-committee cannot register vendor", func(t *testing.T) { - userInvoker.InvokeFail(t, "caller is not a committee member", "registerVendor", vendorAcc.ScriptHash(), "Test Store", state.CategoryFood) + userInvoker.InvokeFail(t, "caller is not a committee member", "registerVendor", vendorAcc.ScriptHash(), "Test Store", state.CategoryFood, false) }) t.Run("committee can register vendor", func(t *testing.T) { - committeeInvoker.Invoke(t, true, "registerVendor", vendorAcc.ScriptHash(), "Test Store", state.CategoryFood) + committeeInvoker.Invoke(t, true, "registerVendor", vendorAcc.ScriptHash(), "Test Store", state.CategoryFood, false) }) t.Run("isVendor returns true", func(t *testing.T) { @@ -199,13 +253,13 @@ func TestVTS_VendorRegistration(t *testing.T) { require.Equal(t, 1, len(stack)) arr, ok := stack[0].Value().([]stackitem.Item) require.True(t, ok, "expected array") - require.Equal(t, 6, len(arr)) // Vendor struct has 6 fields + require.Equal(t, 7, len(arr)) // Vendor struct has 7 fields (including AgeRestricted) }, "getVendor", vendorAcc.ScriptHash()) }) t.Run("update vendor categories", func(t *testing.T) { newCategories := state.CategoryFood | state.CategoryShelter - committeeInvoker.Invoke(t, true, "updateVendor", vendorAcc.ScriptHash(), "Test Store Updated", newCategories) + committeeInvoker.Invoke(t, true, "updateVendor", vendorAcc.ScriptHash(), "Test Store Updated", newCategories, false) c.Invoke(t, int64(newCategories), "getVendorCategories", vendorAcc.ScriptHash()) }) @@ -227,7 +281,7 @@ func TestVTS_Spend(t *testing.T) { customerInvoker := c.WithSigners(customer) // Setup: Register food vendor and mint to customer - committeeInvoker.Invoke(t, true, "registerVendor", foodVendor.ScriptHash(), "Food Store", state.CategoryFood) + committeeInvoker.Invoke(t, true, "registerVendor", foodVendor.ScriptHash(), "Food Store", state.CategoryFood, false) committeeInvoker.Invoke(t, true, "mint", customer.ScriptHash(), 300_00000000) t.Run("customer can spend at vendor", func(t *testing.T) { @@ -258,7 +312,7 @@ func TestVTS_CanSpendAt(t *testing.T) { committeeInvoker := c.WithSigners(c.Committee) // Setup - committeeInvoker.Invoke(t, true, "registerVendor", vendor.ScriptHash(), "Store", state.CategoryFood) + committeeInvoker.Invoke(t, true, "registerVendor", vendor.ScriptHash(), "Store", state.CategoryFood, false) committeeInvoker.Invoke(t, true, "mint", customer.ScriptHash(), 100_00000000) t.Run("can spend within balance", func(t *testing.T) { @@ -279,6 +333,9 @@ func TestVTS_ConvertToUnrestricted(t *testing.T) { committeeInvoker := c.WithSigners(c.Committee) userInvoker := c.WithSigners(acc) + // Restricted VTS requires Vita + registerVita(t, e, acc) + // Mint restricted VTS committeeInvoker.Invoke(t, true, "mintRestricted", acc.ScriptHash(), 500_00000000, state.CategoryFood) @@ -402,6 +459,9 @@ func TestVTS_BalanceDetails(t *testing.T) { acc := e.NewAccount(t) committeeInvoker := c.WithSigners(c.Committee) + // Restricted VTS requires Vita + registerVita(t, e, acc) + // Mint mixed VTS committeeInvoker.Invoke(t, true, "mint", acc.ScriptHash(), 100_00000000) committeeInvoker.Invoke(t, true, "mintRestricted", acc.ScriptHash(), 200_00000000, state.CategoryFood) @@ -481,6 +541,9 @@ func TestVTS_NegativeAmount(t *testing.T) { acc := e.NewAccount(t) committeeInvoker := c.WithSigners(c.Committee) + // Restricted VTS requires Vita + registerVita(t, e, acc) + t.Run("mint negative fails", func(t *testing.T) { committeeInvoker.InvokeFail(t, "amount must be positive", "mint", acc.ScriptHash(), -1) }) @@ -500,9 +563,12 @@ func TestVTS_MultiCategoryVendor(t *testing.T) { committeeInvoker := c.WithSigners(c.Committee) customerInvoker := c.WithSigners(customer) + // Restricted VTS requires Vita + registerVita(t, e, customer) + // Register vendor accepting food AND shelter categories := state.CategoryFood | state.CategoryShelter - committeeInvoker.Invoke(t, true, "registerVendor", generalStore.ScriptHash(), "General Store", categories) + committeeInvoker.Invoke(t, true, "registerVendor", generalStore.ScriptHash(), "General Store", categories, false) // Mint restricted VTS for different categories committeeInvoker.Invoke(t, true, "mintRestricted", customer.ScriptHash(), 100_00000000, state.CategoryFood) @@ -525,3 +591,67 @@ func TestVTS_MultiCategoryVendor(t *testing.T) { c.Invoke(t, 150_00000000, "balanceOf", generalStore.ScriptHash()) }) } + +// TestVTS_AgeRestrictedVendor tests age verification for age-restricted vendors. +func TestVTS_AgeRestrictedVendor(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + adultCustomer := e.NewAccount(t) + minorCustomer := e.NewAccount(t) + liquorStore := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + adultInvoker := c.WithSigners(adultCustomer) + minorInvoker := c.WithSigners(minorCustomer) + + // Register Vita for both customers + registerVita(t, e, adultCustomer) + registerVita(t, e, minorCustomer) + + // Only adult has age_verified attribute + addAgeVerifiedAttribute(t, e, c.Committee, adultCustomer) + + // Register age-restricted vendor (liquor store) + committeeInvoker.Invoke(t, true, "registerVendor", liquorStore.ScriptHash(), "Liquor Store", state.CategoryFood, true) + + // Mint VTS to both customers + committeeInvoker.Invoke(t, true, "mint", adultCustomer.ScriptHash(), 100_00000000) + committeeInvoker.Invoke(t, true, "mint", minorCustomer.ScriptHash(), 100_00000000) + + t.Run("minor cannot spend at age-restricted vendor", func(t *testing.T) { + minorInvoker.InvokeFail(t, "age verification required for this vendor", "spend", minorCustomer.ScriptHash(), liquorStore.ScriptHash(), 10_00000000, nil) + }) + + t.Run("adult can spend at age-restricted vendor", func(t *testing.T) { + adultInvoker.Invoke(t, true, "spend", adultCustomer.ScriptHash(), liquorStore.ScriptHash(), 10_00000000, nil) + }) + + t.Run("canSpendAt returns false for minor at age-restricted vendor", func(t *testing.T) { + c.Invoke(t, false, "canSpendAt", minorCustomer.ScriptHash(), liquorStore.ScriptHash(), 10_00000000) + }) + + t.Run("canSpendAt returns true for adult at age-restricted vendor", func(t *testing.T) { + c.Invoke(t, true, "canSpendAt", adultCustomer.ScriptHash(), liquorStore.ScriptHash(), 10_00000000) + }) +} + +// TestVTS_MintRestrictedRequiresVita tests that minting restricted VTS requires a Vita. +func TestVTS_MintRestrictedRequiresVita(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + accWithVita := e.NewAccount(t) + accWithoutVita := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + // Only register Vita for one account + registerVita(t, e, accWithVita) + + t.Run("cannot mint restricted VTS to account without Vita", func(t *testing.T) { + committeeInvoker.InvokeFail(t, "restricted VTS requires Vita (PersonToken)", "mintRestricted", accWithoutVita.ScriptHash(), 100_00000000, state.CategoryFood) + }) + + t.Run("can mint restricted VTS to account with Vita", func(t *testing.T) { + committeeInvoker.Invoke(t, true, "mintRestricted", accWithVita.ScriptHash(), 100_00000000, state.CategoryFood) + }) +} diff --git a/pkg/core/native/person_token.go b/pkg/core/native/person_token.go index b01eb89..e285bb9 100644 --- a/pkg/core/native/person_token.go +++ b/pkg/core/native/person_token.go @@ -891,6 +891,32 @@ func (p *PersonToken) TokenExists(d *dao.Simple, owner util.Uint160) bool { return p.tokenExistsForOwner(d, owner) } +// IsAdultVerified checks if the owner has a verified "age_verified" attribute +// indicating they are 18+ years old. Used for age-restricted purchases. +// The attribute must be non-revoked and not expired. +func (p *PersonToken) IsAdultVerified(d *dao.Simple, owner util.Uint160) bool { + token, err := p.getTokenByOwnerInternal(d, owner) + if err != nil || token == nil { + return false + } + + // Check for "age_verified" attribute + attr, err := p.getAttributeInternal(d, token.TokenID, "age_verified") + if err != nil || attr == nil { + return false + } + + // Check attribute is not revoked + if attr.Revoked { + return false + } + + // Note: Expiration check would require current block height + // For now, we check if ExpiresAt is set and > 0 means it could expire + // In production, pass block height and compare + return true +} + // Attribute storage methods func (p *PersonToken) putAttribute(d *dao.Simple, tokenID uint64, attr *state.Attribute) error { diff --git a/pkg/core/native/util.go b/pkg/core/native/util.go index 08e5686..d104880 100644 --- a/pkg/core/native/util.go +++ b/pkg/core/native/util.go @@ -60,3 +60,11 @@ func toString(item stackitem.Item) string { } return s } + +func toBool(item stackitem.Item) bool { + b, err := item.TryBool() + if err != nil { + panic(err) + } + return b +} diff --git a/pkg/core/native/vts.go b/pkg/core/native/vts.go index 4a7b5b2..0d1eeac 100644 --- a/pkg/core/native/vts.go +++ b/pkg/core/native/vts.go @@ -41,6 +41,7 @@ type VTS struct { NEO INEO RoleRegistry IRoleRegistry + PersonToken IPersonToken symbol string decimals int64 @@ -150,14 +151,16 @@ func newVTS() *VTS { desc = NewDescriptor("registerVendor", smartcontract.BoolType, manifest.NewParameter("address", smartcontract.Hash160Type), manifest.NewParameter("name", smartcontract.StringType), - manifest.NewParameter("categories", smartcontract.IntegerType)) + manifest.NewParameter("categories", smartcontract.IntegerType), + manifest.NewParameter("ageRestricted", smartcontract.BoolType)) md = NewMethodAndPrice(v.registerVendor, 1<<17, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) desc = NewDescriptor("updateVendor", smartcontract.BoolType, manifest.NewParameter("address", smartcontract.Hash160Type), manifest.NewParameter("name", smartcontract.StringType), - manifest.NewParameter("categories", smartcontract.IntegerType)) + manifest.NewParameter("categories", smartcontract.IntegerType), + manifest.NewParameter("ageRestricted", smartcontract.BoolType)) md = NewMethodAndPrice(v.updateVendor, 1<<17, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) @@ -292,14 +295,16 @@ func newVTS() *VTS { eDesc = NewEventDescriptor("VendorRegistered", manifest.NewParameter("address", smartcontract.Hash160Type), manifest.NewParameter("name", smartcontract.StringType), - manifest.NewParameter("categories", smartcontract.IntegerType)) + manifest.NewParameter("categories", smartcontract.IntegerType), + manifest.NewParameter("ageRestricted", smartcontract.BoolType)) eMD = NewEvent(eDesc) v.AddEvent(eMD) eDesc = NewEventDescriptor("VendorUpdated", manifest.NewParameter("address", smartcontract.Hash160Type), manifest.NewParameter("name", smartcontract.StringType), - manifest.NewParameter("categories", smartcontract.IntegerType)) + manifest.NewParameter("categories", smartcontract.IntegerType), + manifest.NewParameter("ageRestricted", smartcontract.BoolType)) eMD = NewEvent(eDesc) v.AddEvent(eMD) @@ -641,6 +646,12 @@ func (v *VTS) mintRestricted(ic *interop.Context, args []stackitem.Item) stackit panic("use mint() for unrestricted tokens") } + // Restricted VTS requires recipient to have a Vita (PersonToken) + // This ensures benefits go to verified identities + if !v.PersonToken.TokenExists(ic.DAO, to) { + panic("restricted VTS requires Vita (PersonToken)") + } + v.mintRestrictedInternal(ic, to, amount, category) return stackitem.NewBool(true) } @@ -749,6 +760,7 @@ func (v *VTS) registerVendor(ic *interop.Context, args []stackitem.Item) stackit address := toUint160(args[0]) name := toString(args[1]) categories := toUint8(args[2]) + ageRestricted := toBool(args[3]) if len(name) == 0 || len(name) > 64 { panic("invalid vendor name") @@ -760,12 +772,13 @@ func (v *VTS) registerVendor(ic *interop.Context, args []stackitem.Item) stackit } vendor := &state.Vendor{ - Address: address, - Name: name, - Categories: categories, - RegisteredAt: ic.Block.Index, - RegisteredBy: ic.VM.GetCallingScriptHash(), - Active: true, + Address: address, + Name: name, + Categories: categories, + RegisteredAt: ic.Block.Index, + RegisteredBy: ic.VM.GetCallingScriptHash(), + Active: true, + AgeRestricted: ageRestricted, } v.putVendor(ic.DAO, vendor) @@ -778,6 +791,7 @@ func (v *VTS) registerVendor(ic *interop.Context, args []stackitem.Item) stackit stackitem.NewByteArray(address.BytesBE()), stackitem.NewByteArray([]byte(name)), stackitem.NewBigInteger(big.NewInt(int64(categories))), + stackitem.NewBool(ageRestricted), })) return stackitem.NewBool(true) @@ -791,6 +805,7 @@ func (v *VTS) updateVendor(ic *interop.Context, args []stackitem.Item) stackitem address := toUint160(args[0]) name := toString(args[1]) categories := toUint8(args[2]) + ageRestricted := toBool(args[3]) if len(name) == 0 || len(name) > 64 { panic("invalid vendor name") @@ -803,12 +818,14 @@ func (v *VTS) updateVendor(ic *interop.Context, args []stackitem.Item) stackitem vendor.Name = name vendor.Categories = categories + vendor.AgeRestricted = ageRestricted v.putVendor(ic.DAO, vendor) ic.AddNotification(v.Hash, "VendorUpdated", stackitem.NewArray([]stackitem.Item{ stackitem.NewByteArray(address.BytesBE()), stackitem.NewByteArray([]byte(name)), stackitem.NewBigInteger(big.NewInt(int64(categories))), + stackitem.NewBool(ageRestricted), })) return stackitem.NewBool(true) @@ -929,6 +946,13 @@ func (v *VTS) spend(ic *interop.Context, args []stackitem.Item) stackitem.Item { panic("invalid or inactive vendor") } + // Check age verification for age-restricted vendors (e.g., alcohol, tobacco) + if vendorInfo.AgeRestricted { + if !v.PersonToken.IsAdultVerified(ic.DAO, from) { + panic("age verification required for this vendor") + } + } + // Get sender's balance fromBal := v.getBalanceInternal(ic.DAO, from) @@ -1001,6 +1025,13 @@ func (v *VTS) canSpendAt(ic *interop.Context, args []stackitem.Item) stackitem.I return stackitem.NewBool(false) } + // Check age verification for age-restricted vendors + if vendorInfo.AgeRestricted { + if !v.PersonToken.IsAdultVerified(ic.DAO, account) { + return stackitem.NewBool(false) + } + } + // Get balance bal := v.getBalanceInternal(ic.DAO, account) diff --git a/pkg/core/state/vts.go b/pkg/core/state/vts.go index 0516382..5c10df7 100644 --- a/pkg/core/state/vts.go +++ b/pkg/core/state/vts.go @@ -112,12 +112,13 @@ func (b *VTSBalance) FromStackItem(item stackitem.Item) error { // Vendor represents a registered vendor/merchant that can accept VTS payments. type Vendor struct { - Address util.Uint160 // Vendor's script hash - Name string // Display name (max 64 chars) - Categories uint8 // Bitmask of accepted spending categories - RegisteredAt uint32 // Block height when registered - RegisteredBy util.Uint160 // Who registered this vendor - Active bool // Whether vendor is currently active + Address util.Uint160 // Vendor's script hash + Name string // Display name (max 64 chars) + Categories uint8 // Bitmask of accepted spending categories + RegisteredAt uint32 // Block height when registered + RegisteredBy util.Uint160 // Who registered this vendor + Active bool // Whether vendor is currently active + AgeRestricted bool // Whether vendor requires age verification (e.g., alcohol, tobacco) } // ToStackItem converts Vendor to stackitem. @@ -129,6 +130,7 @@ func (v *Vendor) ToStackItem() (stackitem.Item, error) { stackitem.NewBigInteger(big.NewInt(int64(v.RegisteredAt))), stackitem.NewByteArray(v.RegisteredBy.BytesBE()), stackitem.NewBool(v.Active), + stackitem.NewBool(v.AgeRestricted), }), nil } @@ -181,6 +183,15 @@ func (v *Vendor) FromStackItem(item stackitem.Item) error { } v.Active = active + // AgeRestricted is optional for backwards compatibility + if len(structItems) >= 7 { + ageRestricted, err := structItems[6].TryBool() + if err != nil { + return fmt.Errorf("invalid ageRestricted: %w", err) + } + v.AgeRestricted = ageRestricted + } + return nil }