Add VTS PersonToken integration and age-restricted spending

- Require Vita (soul-bound identity token) for minting restricted VTS
- Add age-restricted vendor flag for age-gated purchases (e.g., alcohol)
- Add IsAdultVerified method to PersonToken for age verification
- Update spend/canSpendAt to check age verification for restricted vendors
- Add toBool helper function for boolean stackitem parsing
- Add comprehensive tests for age-restricted functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Tutus Development 2025-12-20 03:16:33 +00:00
parent 86f5e127c0
commit b5c1dca2c6
6 changed files with 233 additions and 23 deletions

View File

@ -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,

View File

@ -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)
})
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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
}