package native import ( "encoding/binary" "errors" "fmt" "math/big" "git.marketally.com/tutus-one/tutus-chain/pkg/config" "git.marketally.com/tutus-one/tutus-chain/pkg/core/dao" "git.marketally.com/tutus-one/tutus-chain/pkg/core/interop" "git.marketally.com/tutus-one/tutus-chain/pkg/core/interop/runtime" "git.marketally.com/tutus-one/tutus-chain/pkg/core/native/nativeids" "git.marketally.com/tutus-one/tutus-chain/pkg/core/native/nativenames" "git.marketally.com/tutus-one/tutus-chain/pkg/core/state" "git.marketally.com/tutus-one/tutus-chain/pkg/core/storage" "git.marketally.com/tutus-one/tutus-chain/pkg/crypto/hash" "git.marketally.com/tutus-one/tutus-chain/pkg/smartcontract" "git.marketally.com/tutus-one/tutus-chain/pkg/smartcontract/callflag" "git.marketally.com/tutus-one/tutus-chain/pkg/smartcontract/manifest" "git.marketally.com/tutus-one/tutus-chain/pkg/util" "git.marketally.com/tutus-one/tutus-chain/pkg/vm/stackitem" ) // Vita represents a soul-bound identity native contract. type Vita struct { interop.ContractMD Tutus ITutus RoleRegistry IRoleRegistry Lex ILex Annos IAnnos } // VitaCache represents the cached state for Vita contract. type VitaCache struct { tokenCount uint64 } // Storage key prefixes for Vita. const ( prefixTokenByOwner = 0x01 // owner (Uint160) -> tokenID (uint64) prefixTokenByID = 0x02 // tokenID (uint64) -> Vita token prefixPersonHash = 0x03 // personHash -> tokenID (uniqueness) prefixAttribute = 0x04 // tokenID + attrKey -> Attribute prefixChallenge = 0x05 // challengeID -> AuthChallenge prefixRecovery = 0x06 // requestID -> RecoveryRequest prefixActiveRecovery = 0x07 // tokenID + "recovery" -> requestID prefixPresence = 0x08 // tokenID -> PresenceRecord (VPP) prefixPresenceConfig = 0x09 // -> PresenceConfig prefixTokenCounter = 0x10 // -> uint64 prefixConfig = 0x11 // -> VitaConfig ) // Event names for Vita. const ( VitaCreatedEvent = "VitaCreated" VitaSuspendedEvent = "VitaSuspended" VitaReinstatedEvent = "VitaReinstated" VitaRevokedEvent = "VitaRevoked" AttributeSetEvent = "AttributeSet" AttributeRevokedEvent = "AttributeRevoked" AuthenticationSuccessEvent = "AuthenticationSuccess" AuthChallengeCreatedEvent = "AuthChallengeCreated" RecoveryInitiatedEvent = "RecoveryInitiated" RecoveryApprovalEvent = "RecoveryApproval" RecoveryExecutedEvent = "RecoveryExecuted" RecoveryCancelledEvent = "RecoveryCancelled" VestingUpdatedEvent = "VestingUpdated" PresenceRecordedEvent = "PresenceRecorded" PresenceVisibilityEvent = "PresenceVisibilityChanged" ) // Default vesting period in blocks (approximately 30 days at 1 second per block). // New Vita tokens must vest before having full rights (Sybil resistance). const defaultVestingPeriod uint32 = 2592000 // 30 days * 24 hours * 60 minutes * 60 seconds // Various errors. var ( ErrTokenAlreadyExists = errors.New("token already exists for this owner") ErrPersonHashExists = errors.New("person hash already linked to another token") ErrTokenNotFound = errors.New("token not found") ErrTokenSuspended = errors.New("token is suspended") ErrTokenRevoked = errors.New("token is revoked") ErrTokenNotSuspended = errors.New("token is not suspended") ErrInvalidOwner = errors.New("invalid owner") ErrInvalidPersonHash = errors.New("invalid person hash") ErrNotCommittee = errors.New("invalid committee signature") ErrVitaInvalidWitness = errors.New("invalid witness") ErrVitaNotFullyVested = errors.New("vita is not fully vested") ErrAttributeNotFound = errors.New("attribute not found") ErrAttributeRevoked = errors.New("attribute is already revoked") ErrAttributeExpired = errors.New("attribute has expired") ErrInvalidAttributeKey = errors.New("invalid attribute key") ErrInvalidValueHash = errors.New("invalid value hash") ErrInvalidDisclosureLevel = errors.New("invalid disclosure level") ErrTokenNotActive = errors.New("token is not active") ErrChallengeNotFound = errors.New("challenge not found") ErrChallengeExpired = errors.New("challenge has expired") ErrChallengeAlreadyFulfilled = errors.New("challenge already fulfilled") ErrInvalidSignature = errors.New("invalid signature") ErrInvalidPurpose = errors.New("invalid purpose") ErrNoRecentAuth = errors.New("no recent authentication found") ErrRecoveryNotFound = errors.New("recovery request not found") ErrRecoveryAlreadyActive = errors.New("recovery already active for this token") ErrRecoveryNotPending = errors.New("recovery is not in pending state") ErrRecoveryDelayNotPassed = errors.New("recovery delay period has not passed") ErrRecoveryExpired = errors.New("recovery request has expired") ErrAlreadyApproved = errors.New("already approved this recovery") ErrInvalidNewOwner = errors.New("invalid new owner") ErrTokenInRecovery = errors.New("token is already in recovery") ErrNotRecoveryRequester = errors.New("not the recovery requester") ErrCallerHasNoVita = errors.New("caller does not have a Vita") ErrRoleNotAssigned = errors.New("required role not assigned") ErrPermissionDenied = errors.New("permission denied") ErrVitaInvalidRole = errors.New("invalid core role") ErrVitaInvalidResource = errors.New("invalid resource") ErrVitaInvalidAction = errors.New("invalid action") // Presence (VPP) errors ErrPresenceRateLimited = errors.New("heartbeat rate limit exceeded") ErrPresenceInvalidSession = errors.New("invalid session ID: must be 32 bytes") ErrPresenceInvalidDevice = errors.New("invalid device hash: must be 32 bytes") ErrPresenceNotFound = errors.New("no presence record found") ) // CoreRole represents built-in roles for cross-contract access control. type CoreRole uint8 // Core roles for Vita holders. const ( CoreRoleNone CoreRole = 0 CoreRoleUser CoreRole = 1 // Basic authenticated user CoreRoleVerified CoreRole = 2 // User with verified attributes CoreRoleCommittee CoreRole = 3 // Committee member CoreRoleAttestor CoreRole = 4 // Can attest attributes for others CoreRoleRecovery CoreRole = 5 // Can participate in recovery ) var ( _ interop.Contract = (*Vita)(nil) _ dao.NativeContractCache = (*VitaCache)(nil) ) // Copy implements NativeContractCache interface. func (c *VitaCache) Copy() dao.NativeContractCache { return &VitaCache{ tokenCount: c.tokenCount, } } // checkCommittee checks if the caller has committee authority. // Uses RoleRegistry if available, falls back to NEO.CheckCommittee(). func (v *Vita) checkCommittee(ic *interop.Context) bool { if v.RoleRegistry != nil { return v.RoleRegistry.CheckCommittee(ic) } // Fallback to Tutus for backwards compatibility return v.Tutus.CheckCommittee(ic) } // newVita creates a new Vita native contract. func newVita() *Vita { v := &Vita{ ContractMD: *interop.NewContractMD(nativenames.Vita, nativeids.Vita), } defer v.BuildHFSpecificMD(v.ActiveIn()) // Register method desc := NewDescriptor("register", smartcontract.ByteArrayType, manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("personHash", smartcontract.ByteArrayType), manifest.NewParameter("isEntity", smartcontract.BoolType), manifest.NewParameter("recoveryHash", smartcontract.ByteArrayType), manifest.NewParameter("birthTimestamp", smartcontract.IntegerType)) md := NewMethodAndPrice(v.register, 1<<17, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // GetToken method desc = NewDescriptor("getToken", smartcontract.ArrayType, manifest.NewParameter("owner", smartcontract.Hash160Type)) md = NewMethodAndPrice(v.getToken, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // GetTokenByID method desc = NewDescriptor("getTokenByID", smartcontract.ArrayType, manifest.NewParameter("tokenId", smartcontract.IntegerType)) md = NewMethodAndPrice(v.getTokenByID, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // Exists method desc = NewDescriptor("exists", smartcontract.BoolType, manifest.NewParameter("owner", smartcontract.Hash160Type)) md = NewMethodAndPrice(v.exists, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // TotalSupply method desc = NewDescriptor("totalSupply", smartcontract.IntegerType) md = NewMethodAndPrice(v.totalSupply, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // Suspend method desc = NewDescriptor("suspend", smartcontract.BoolType, manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("reason", smartcontract.StringType)) md = NewMethodAndPrice(v.suspend, 1<<16, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // Reinstate method desc = NewDescriptor("reinstate", smartcontract.BoolType, manifest.NewParameter("owner", smartcontract.Hash160Type)) md = NewMethodAndPrice(v.reinstate, 1<<16, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // Revoke method desc = NewDescriptor("revoke", smartcontract.BoolType, manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("reason", smartcontract.StringType)) md = NewMethodAndPrice(v.revoke, 1<<17, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // SetAttribute method desc = NewDescriptor("setAttribute", smartcontract.BoolType, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("key", smartcontract.StringType), manifest.NewParameter("valueHash", smartcontract.ByteArrayType), manifest.NewParameter("valueEnc", smartcontract.ByteArrayType), manifest.NewParameter("expiresAt", smartcontract.IntegerType), manifest.NewParameter("disclosureLevel", smartcontract.IntegerType)) md = NewMethodAndPrice(v.setAttribute, 1<<17, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // GetAttribute method desc = NewDescriptor("getAttribute", smartcontract.ArrayType, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("key", smartcontract.StringType)) md = NewMethodAndPrice(v.getAttribute, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // RevokeAttribute method desc = NewDescriptor("revokeAttribute", smartcontract.BoolType, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("key", smartcontract.StringType), manifest.NewParameter("reason", smartcontract.StringType)) md = NewMethodAndPrice(v.revokeAttribute, 1<<16, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // VerifyAttribute method desc = NewDescriptor("verifyAttribute", smartcontract.BoolType, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("key", smartcontract.StringType), manifest.NewParameter("expectedHash", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.verifyAttribute, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // CreateChallenge method desc = NewDescriptor("createChallenge", smartcontract.ArrayType, manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("purpose", smartcontract.StringType)) md = NewMethodAndPrice(v.createChallenge, 1<<16, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // GetChallenge method desc = NewDescriptor("getChallenge", smartcontract.ArrayType, manifest.NewParameter("challengeId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.getChallenge, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // FulfillChallenge method desc = NewDescriptor("fulfillChallenge", smartcontract.BoolType, manifest.NewParameter("challengeId", smartcontract.ByteArrayType), manifest.NewParameter("signature", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.fulfillChallenge, 1<<17, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // VerifyAuth method desc = NewDescriptor("verifyAuth", smartcontract.BoolType, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("purpose", smartcontract.StringType), manifest.NewParameter("maxAge", smartcontract.IntegerType)) md = NewMethodAndPrice(v.verifyAuth, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // InitiateRecovery method desc = NewDescriptor("initiateRecovery", smartcontract.ByteArrayType, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("newOwner", smartcontract.Hash160Type), manifest.NewParameter("evidence", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.initiateRecovery, 1<<17, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // ApproveRecovery method desc = NewDescriptor("approveRecovery", smartcontract.BoolType, manifest.NewParameter("requestId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.approveRecovery, 1<<16, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // ExecuteRecovery method desc = NewDescriptor("executeRecovery", smartcontract.BoolType, manifest.NewParameter("requestId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.executeRecovery, 1<<17, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // CancelRecovery method desc = NewDescriptor("cancelRecovery", smartcontract.BoolType, manifest.NewParameter("requestId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.cancelRecovery, 1<<16, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // GetRecoveryRequest method desc = NewDescriptor("getRecoveryRequest", smartcontract.ArrayType, manifest.NewParameter("requestId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.getRecoveryRequest, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // ValidateCaller method desc = NewDescriptor("validateCaller", smartcontract.ArrayType) md = NewMethodAndPrice(v.validateCaller, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // RequireRole method desc = NewDescriptor("requireRole", smartcontract.IntegerType, manifest.NewParameter("roleId", smartcontract.IntegerType)) md = NewMethodAndPrice(v.requireRole, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // RequireCoreRole method desc = NewDescriptor("requireCoreRole", smartcontract.IntegerType, manifest.NewParameter("coreRole", smartcontract.IntegerType)) md = NewMethodAndPrice(v.requireCoreRole, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // RequirePermission method desc = NewDescriptor("requirePermission", smartcontract.IntegerType, manifest.NewParameter("resource", smartcontract.StringType), manifest.NewParameter("action", smartcontract.StringType), manifest.NewParameter("scope", smartcontract.StringType)) md = NewMethodAndPrice(v.requirePermission, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // SetVesting method (committee only) desc = NewDescriptor("setVesting", smartcontract.BoolType, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("vestedUntil", smartcontract.IntegerType)) md = NewMethodAndPrice(v.setVesting, 1<<16, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // IsFullyVested method desc = NewDescriptor("isFullyVested", smartcontract.BoolType, manifest.NewParameter("tokenId", smartcontract.IntegerType)) md = NewMethodAndPrice(v.isFullyVested, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // GetVestingInfo method desc = NewDescriptor("getVestingInfo", smartcontract.ArrayType, manifest.NewParameter("tokenId", smartcontract.IntegerType)) md = NewMethodAndPrice(v.getVestingInfo, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // Events eDesc := NewEventDescriptor(VitaCreatedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("createdAt", smartcontract.IntegerType)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(VitaSuspendedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), manifest.NewParameter("reason", smartcontract.StringType), manifest.NewParameter("suspendedBy", smartcontract.Hash160Type)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(VitaReinstatedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), manifest.NewParameter("reinstatedBy", smartcontract.Hash160Type)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(VitaRevokedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), manifest.NewParameter("reason", smartcontract.StringType), manifest.NewParameter("revokedBy", smartcontract.Hash160Type)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(AttributeSetEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("key", smartcontract.StringType), manifest.NewParameter("attestor", smartcontract.Hash160Type), manifest.NewParameter("expiresAt", smartcontract.IntegerType)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(AttributeRevokedEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("key", smartcontract.StringType), manifest.NewParameter("revokedBy", smartcontract.Hash160Type), manifest.NewParameter("reason", smartcontract.StringType)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(AuthChallengeCreatedEvent, manifest.NewParameter("challengeId", smartcontract.ByteArrayType), manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("purpose", smartcontract.StringType), manifest.NewParameter("expiresAt", smartcontract.IntegerType)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(AuthenticationSuccessEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("purpose", smartcontract.StringType), manifest.NewParameter("timestamp", smartcontract.IntegerType)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(RecoveryInitiatedEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("requestId", smartcontract.ByteArrayType), manifest.NewParameter("delayUntil", smartcontract.IntegerType)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(RecoveryApprovalEvent, manifest.NewParameter("requestId", smartcontract.ByteArrayType), manifest.NewParameter("approver", smartcontract.Hash160Type), manifest.NewParameter("approvalCount", smartcontract.IntegerType), manifest.NewParameter("required", smartcontract.IntegerType)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(RecoveryExecutedEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("oldOwner", smartcontract.Hash160Type), manifest.NewParameter("newOwner", smartcontract.Hash160Type)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(RecoveryCancelledEvent, manifest.NewParameter("requestId", smartcontract.ByteArrayType), manifest.NewParameter("cancelledBy", smartcontract.Hash160Type)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(VestingUpdatedEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("vestedUntil", smartcontract.IntegerType), manifest.NewParameter("updatedBy", smartcontract.Hash160Type)) v.AddEvent(NewEvent(eDesc)) // VPP (Vita Presence Protocol) methods // RecordPresence method - record a heartbeat for VPP desc = NewDescriptor("recordPresence", smartcontract.BoolType, manifest.NewParameter("sessionId", smartcontract.ByteArrayType), manifest.NewParameter("deviceHash", smartcontract.ByteArrayType)) md = NewMethodAndPrice(v.recordPresence, 1<<15, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // GetPresence method - get presence record for a token desc = NewDescriptor("getPresence", smartcontract.ArrayType, manifest.NewParameter("tokenId", smartcontract.IntegerType)) md = NewMethodAndPrice(v.getPresence, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // GetPresenceStatus method - get computed presence status desc = NewDescriptor("getPresenceStatus", smartcontract.IntegerType, manifest.NewParameter("tokenId", smartcontract.IntegerType)) md = NewMethodAndPrice(v.getPresenceStatus, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // SetPresenceVisibility method - set visibility (invisible mode) desc = NewDescriptor("setPresenceVisibility", smartcontract.BoolType, manifest.NewParameter("invisible", smartcontract.BoolType)) md = NewMethodAndPrice(v.setPresenceVisibility, 1<<15, callflag.States|callflag.AllowNotify) v.AddMethod(md, desc) // GetPresenceConfig method - get VPP configuration desc = NewDescriptor("getPresenceConfig", smartcontract.ArrayType) md = NewMethodAndPrice(v.getPresenceConfig, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) // VPP Events eDesc = NewEventDescriptor(PresenceRecordedEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("blockHeight", smartcontract.IntegerType), manifest.NewParameter("sessionId", smartcontract.ByteArrayType)) v.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(PresenceVisibilityEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("invisible", smartcontract.BoolType)) v.AddEvent(NewEvent(eDesc)) return v } // Metadata returns contract metadata. func (v *Vita) Metadata() *interop.ContractMD { return &v.ContractMD } // Initialize initializes Vita contract at the specified hardfork. func (v *Vita) Initialize(ic *interop.Context, hf *config.Hardfork, newMD *interop.HFSpecificContractMD) error { if hf != v.ActiveIn() { return nil } cache := &VitaCache{ tokenCount: 0, } ic.DAO.SetCache(v.ID, cache) // Initialize token counter to 0 v.setTokenCounter(ic.DAO, 0) return nil } // InitializeCache fills native Vita cache from DAO on node restart. func (v *Vita) InitializeCache(_ interop.IsHardforkEnabled, blockHeight uint32, d *dao.Simple) error { cache := &VitaCache{} // Load token counter from storage counter := v.getTokenCounter(d) cache.tokenCount = counter d.SetCache(v.ID, cache) return nil } // OnPersist implements the Contract interface. func (v *Vita) OnPersist(ic *interop.Context) error { return nil } // PostPersist implements the Contract interface. func (v *Vita) PostPersist(ic *interop.Context) error { return nil } // ActiveIn returns the hardfork this contract activates in. // Vita is always active (returns nil). func (v *Vita) ActiveIn() *config.Hardfork { return nil } // Storage key helpers func makeTokenByOwnerKey(owner util.Uint160) []byte { key := make([]byte, 1+util.Uint160Size) key[0] = prefixTokenByOwner copy(key[1:], owner.BytesBE()) return key } func makeTokenByIDKey(tokenID uint64) []byte { key := make([]byte, 9) key[0] = prefixTokenByID binary.BigEndian.PutUint64(key[1:], tokenID) return key } func makePersonHashKey(personHash []byte) []byte { key := make([]byte, 1+len(personHash)) key[0] = prefixPersonHash copy(key[1:], personHash) return key } func makeAttributeKey(tokenID uint64, attrKey string) []byte { keyBytes := []byte(attrKey) key := make([]byte, 9+len(keyBytes)) key[0] = prefixAttribute binary.BigEndian.PutUint64(key[1:9], tokenID) copy(key[9:], keyBytes) return key } func makeChallengeKey(challengeID util.Uint256) []byte { key := make([]byte, 1+util.Uint256Size) key[0] = prefixChallenge copy(key[1:], challengeID.BytesBE()) return key } func makeLastAuthKey(tokenID uint64, purpose string) []byte { purposeBytes := []byte(purpose) key := make([]byte, 9+len(purposeBytes)) key[0] = prefixConfig // Using config prefix with tokenID+purpose for last auth tracking binary.BigEndian.PutUint64(key[1:9], tokenID) copy(key[9:], purposeBytes) return key } func makeRecoveryKey(requestID util.Uint256) []byte { key := make([]byte, 1+util.Uint256Size) key[0] = prefixRecovery copy(key[1:], requestID.BytesBE()) return key } func makeActiveRecoveryKey(tokenID uint64) []byte { key := make([]byte, 9) key[0] = prefixActiveRecovery binary.BigEndian.PutUint64(key[1:], tokenID) return key } // Token counter methods func (v *Vita) getTokenCounter(d *dao.Simple) uint64 { si := d.GetStorageItem(v.ID, []byte{prefixTokenCounter}) if si == nil { return 0 } return binary.BigEndian.Uint64(si) } func (v *Vita) setTokenCounter(d *dao.Simple, count uint64) { buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, count) d.PutStorageItem(v.ID, []byte{prefixTokenCounter}, buf) } func (v *Vita) getAndIncrementTokenCounter(d *dao.Simple) uint64 { cache := d.GetRWCache(v.ID).(*VitaCache) tokenID := cache.tokenCount cache.tokenCount++ v.setTokenCounter(d, cache.tokenCount) return tokenID } // Token storage methods func (v *Vita) putToken(d *dao.Simple, token *state.Vita) error { item, err := token.ToStackItem() if err != nil { return err } data, err := stackitem.Serialize(item) if err != nil { return err } // Store by ID d.PutStorageItem(v.ID, makeTokenByIDKey(token.TokenID), data) // Store owner -> tokenID mapping tokenIDBytes := make([]byte, 8) binary.BigEndian.PutUint64(tokenIDBytes, token.TokenID) d.PutStorageItem(v.ID, makeTokenByOwnerKey(token.Owner), tokenIDBytes) // Store personHash -> tokenID mapping for uniqueness if len(token.PersonHash) > 0 { d.PutStorageItem(v.ID, makePersonHashKey(token.PersonHash), tokenIDBytes) } return nil } func (v *Vita) getTokenByOwnerInternal(d *dao.Simple, owner util.Uint160) (*state.Vita, error) { si := d.GetStorageItem(v.ID, makeTokenByOwnerKey(owner)) if si == nil { return nil, nil } tokenID := binary.BigEndian.Uint64(si) return v.getTokenByIDInternal(d, tokenID) } func (v *Vita) getTokenByIDInternal(d *dao.Simple, tokenID uint64) (*state.Vita, error) { si := d.GetStorageItem(v.ID, makeTokenByIDKey(tokenID)) if si == nil { return nil, nil } item, err := stackitem.Deserialize(si) if err != nil { return nil, err } token := new(state.Vita) if err := token.FromStackItem(item); err != nil { return nil, err } return token, nil } func (v *Vita) tokenExistsForOwner(d *dao.Simple, owner util.Uint160) bool { si := d.GetStorageItem(v.ID, makeTokenByOwnerKey(owner)) return si != nil } func (v *Vita) personHashExists(d *dao.Simple, personHash []byte) bool { if len(personHash) == 0 { return false } si := d.GetStorageItem(v.ID, makePersonHashKey(personHash)) return si != nil } // Contract methods // register creates a new Vita. func (v *Vita) register(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) personHash := toBytes(args[1]) isEntity, err := args[2].TryBool() if err != nil { panic(fmt.Errorf("invalid isEntity: %w", err)) } recoveryHash := toBytes(args[3]) birthTimestamp := toUint64(args[4]) // Validate owner if owner.Equals(util.Uint160{}) { panic(ErrInvalidOwner) } // Check witness for the owner ok, err := runtime.CheckHashedWitness(ic, owner) if err != nil || !ok { panic(ErrVitaInvalidWitness) } // Check if token already exists for this owner if v.tokenExistsForOwner(ic.DAO, owner) { panic(ErrTokenAlreadyExists) } // Check if person hash is already linked to another token if v.personHashExists(ic.DAO, personHash) { panic(ErrPersonHashExists) } // Get next token ID tokenID := v.getAndIncrementTokenCounter(ic.DAO) // Create token with vesting period for Sybil resistance // New tokens must vest before having full rights vestedUntil := ic.Block.Index + defaultVestingPeriod token := &state.Vita{ TokenID: tokenID, Owner: owner, PersonHash: personHash, IsEntity: isEntity, CreatedAt: ic.Block.Index, UpdatedAt: ic.Block.Index, Status: state.TokenStatusActive, StatusReason: "", RecoveryHash: recoveryHash, VestedUntil: vestedUntil, } // Store token if err := v.putToken(ic.DAO, token); err != nil { panic(err) } // Register birth in Annos contract for lifespan tracking // birthTimestamp is the actual birth date (Unix timestamp in seconds) // This allows existing adults to register with their real birth date // For newborns being registered at birth, use current block timestamp if v.Annos != nil { if err := v.Annos.RegisterBirthInternal(ic.DAO, ic, tokenID, owner, birthTimestamp); err != nil { panic(fmt.Errorf("failed to register birth in Annos: %w", err)) } } // Generate token ID bytes for return and event tokenIDBytes := hash.Sha256(append(owner.BytesBE(), personHash...)).BytesBE() // Emit event err = ic.AddNotification(v.Hash, VitaCreatedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewByteArray(tokenIDBytes), stackitem.NewByteArray(owner.BytesBE()), stackitem.NewBigInteger(big.NewInt(int64(ic.Block.Index))), })) if err != nil { panic(err) } return stackitem.NewByteArray(tokenIDBytes) } // getToken returns the token for the given owner. func (v *Vita) getToken(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) token, err := v.getTokenByOwnerInternal(ic.DAO, owner) if err != nil { panic(err) } if token == nil { return stackitem.Null{} } item, err := token.ToStackItem() if err != nil { panic(err) } return item } // getTokenByID returns the token for the given token ID. func (v *Vita) getTokenByID(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() token, err := v.getTokenByIDInternal(ic.DAO, tokenID) if err != nil { panic(err) } if token == nil { return stackitem.Null{} } item, err := token.ToStackItem() if err != nil { panic(err) } return item } // exists checks if a token exists for the given owner. func (v *Vita) exists(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) return stackitem.NewBool(v.tokenExistsForOwner(ic.DAO, owner)) } // totalSupply returns the total number of tokens. func (v *Vita) totalSupply(ic *interop.Context, _ []stackitem.Item) stackitem.Item { cache := ic.DAO.GetROCache(v.ID).(*VitaCache) return stackitem.NewBigInteger(big.NewInt(int64(cache.tokenCount))) } // suspend temporarily suspends a token (committee only). func (v *Vita) suspend(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) reason := toString(args[1]) // Check committee if !v.checkCommittee(ic) { panic(ErrNotCommittee) } // Require liberty restriction order from Lex (due process protection) // Suspending a Vita is a restriction of liberty, which requires judicial authority if v.Lex != nil && !v.Lex.IsRestrictedInternal(ic.DAO, owner, state.RightLiberty, ic.Block.Index) { panic("liberty restriction order required via Lex (due process)") } // Get token token, err := v.getTokenByOwnerInternal(ic.DAO, owner) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Check if already revoked if token.Status == state.TokenStatusRevoked { panic(ErrTokenRevoked) } // Update status token.Status = state.TokenStatusSuspended token.StatusReason = reason token.UpdatedAt = ic.Block.Index // Store updated token if err := v.putToken(ic.DAO, token); err != nil { panic(err) } // Get caller for event caller := ic.VM.GetCallingScriptHash() // Generate token ID bytes tokenIDBytes := make([]byte, 8) binary.BigEndian.PutUint64(tokenIDBytes, token.TokenID) // Emit event err = ic.AddNotification(v.Hash, VitaSuspendedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewByteArray(tokenIDBytes), stackitem.NewByteArray([]byte(reason)), stackitem.NewByteArray(caller.BytesBE()), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // reinstate reinstates a suspended token (committee only). func (v *Vita) reinstate(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) // Check committee if !v.checkCommittee(ic) { panic(ErrNotCommittee) } // Get token token, err := v.getTokenByOwnerInternal(ic.DAO, owner) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Check if suspended if token.Status != state.TokenStatusSuspended { panic(ErrTokenNotSuspended) } // Update status token.Status = state.TokenStatusActive token.StatusReason = "" token.UpdatedAt = ic.Block.Index // Store updated token if err := v.putToken(ic.DAO, token); err != nil { panic(err) } // Get caller for event caller := ic.VM.GetCallingScriptHash() // Generate token ID bytes tokenIDBytes := make([]byte, 8) binary.BigEndian.PutUint64(tokenIDBytes, token.TokenID) // Emit event err = ic.AddNotification(v.Hash, VitaReinstatedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewByteArray(tokenIDBytes), stackitem.NewByteArray(caller.BytesBE()), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // revoke permanently revokes a token (committee only). func (v *Vita) revoke(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) reason := toString(args[1]) // Check committee if !v.checkCommittee(ic) { panic(ErrNotCommittee) } // Require liberty restriction order from Lex (due process protection) // Revoking a Vita is a permanent restriction of liberty, requiring judicial authority if v.Lex != nil && !v.Lex.IsRestrictedInternal(ic.DAO, owner, state.RightLiberty, ic.Block.Index) { panic("liberty restriction order required via Lex (due process)") } // Get token token, err := v.getTokenByOwnerInternal(ic.DAO, owner) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Check if already revoked if token.Status == state.TokenStatusRevoked { panic(ErrTokenRevoked) } // Update status token.Status = state.TokenStatusRevoked token.StatusReason = reason token.UpdatedAt = ic.Block.Index // Store updated token if err := v.putToken(ic.DAO, token); err != nil { panic(err) } // Get caller for event caller := ic.VM.GetCallingScriptHash() // Generate token ID bytes tokenIDBytes := make([]byte, 8) binary.BigEndian.PutUint64(tokenIDBytes, token.TokenID) // Emit event err = ic.AddNotification(v.Hash, VitaRevokedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewByteArray(tokenIDBytes), stackitem.NewByteArray([]byte(reason)), stackitem.NewByteArray(caller.BytesBE()), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // Public methods for cross-native access // GetTokenByOwner returns the token for the given owner (for cross-native access). func (v *Vita) GetTokenByOwner(d *dao.Simple, owner util.Uint160) (*state.Vita, error) { return v.getTokenByOwnerInternal(d, owner) } // GetTokenByIDPublic returns the token for the given ID (for cross-native access). func (v *Vita) GetTokenByIDPublic(d *dao.Simple, tokenID uint64) (*state.Vita, error) { return v.getTokenByIDInternal(d, tokenID) } // TokenExists returns true if a token exists for the given owner. func (v *Vita) TokenExists(d *dao.Simple, owner util.Uint160) bool { return v.tokenExistsForOwner(d, owner) } // GetTotalTokenCount returns the total number of tokens issued (for quorum calculations). func (v *Vita) GetTotalTokenCount(d *dao.Simple) uint64 { return v.getTokenCounter(d) } // ExistsInternal checks if a Vita token with the given ID exists. func (v *Vita) ExistsInternal(d *dao.Simple, vitaID uint64) bool { token, err := v.getTokenByIDInternal(d, vitaID) return err == nil && token != nil } // OwnerOfInternal returns the owner of a Vita token by ID. // Returns empty Uint160 if token doesn't exist. func (v *Vita) OwnerOfInternal(d *dao.Simple, vitaID uint64) util.Uint160 { token, err := v.getTokenByIDInternal(d, vitaID) if err != nil || token == nil { return util.Uint160{} } return token.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 (v *Vita) IsAdultVerified(d *dao.Simple, owner util.Uint160) bool { token, err := v.getTokenByOwnerInternal(d, owner) if err != nil || token == nil { return false } // Check for "age_verified" attribute attr, err := v.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 (v *Vita) putAttribute(d *dao.Simple, tokenID uint64, attr *state.Attribute) error { item, err := attr.ToStackItem() if err != nil { return err } data, err := stackitem.Serialize(item) if err != nil { return err } d.PutStorageItem(v.ID, makeAttributeKey(tokenID, attr.Key), data) return nil } func (v *Vita) getAttributeInternal(d *dao.Simple, tokenID uint64, key string) (*state.Attribute, error) { si := d.GetStorageItem(v.ID, makeAttributeKey(tokenID, key)) if si == nil { return nil, nil } item, err := stackitem.Deserialize(si) if err != nil { return nil, err } attr := new(state.Attribute) if err := attr.FromStackItem(item); err != nil { return nil, err } return attr, nil } // setAttribute sets or updates an attribute on a token. // The caller must be either the token owner (self-attestation) or any other account (third-party attestation). func (v *Vita) setAttribute(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() key := toString(args[1]) valueHash := toBytes(args[2]) valueEnc := toBytes(args[3]) expiresAt := uint32(toBigInt(args[4]).Int64()) disclosureLevel := state.DisclosureLevel(toBigInt(args[5]).Int64()) // Validate inputs if len(key) == 0 || len(key) > 64 { panic(ErrInvalidAttributeKey) } if len(valueHash) == 0 || len(valueHash) > 64 { panic(ErrInvalidValueHash) } if disclosureLevel > state.DisclosurePublic { panic(ErrInvalidDisclosureLevel) } // Get token token, err := v.getTokenByIDInternal(ic.DAO, tokenID) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Check token is active if token.Status != state.TokenStatusActive { panic(ErrTokenNotActive) } // Get the attestor (caller) attestor := ic.VM.GetCallingScriptHash() // If attestor is the owner, check witness // Otherwise, any contract/account can attest (third-party attestation) if attestor.Equals(token.Owner) { ok, err := runtime.CheckHashedWitness(ic, token.Owner) if err != nil || !ok { panic(ErrVitaInvalidWitness) } } // Create attribute attr := &state.Attribute{ Key: key, ValueHash: valueHash, ValueEnc: valueEnc, Attestor: attestor, AttestedAt: ic.Block.Index, ExpiresAt: expiresAt, Revoked: false, RevokedAt: 0, DisclosureLevel: disclosureLevel, } // Store attribute if err := v.putAttribute(ic.DAO, tokenID, attr); err != nil { panic(err) } // Emit event err = ic.AddNotification(v.Hash, AttributeSetEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(tokenID))), stackitem.NewByteArray([]byte(key)), stackitem.NewByteArray(attestor.BytesBE()), stackitem.NewBigInteger(big.NewInt(int64(expiresAt))), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // getAttribute returns an attribute for a token. func (v *Vita) getAttribute(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() key := toString(args[1]) attr, err := v.getAttributeInternal(ic.DAO, tokenID, key) if err != nil { panic(err) } if attr == nil { return stackitem.Null{} } item, err := attr.ToStackItem() if err != nil { panic(err) } return item } // revokeAttribute revokes an attribute. // Can be called by the token owner, the original attestor, or committee. func (v *Vita) revokeAttribute(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() key := toString(args[1]) reason := toString(args[2]) // Get token token, err := v.getTokenByIDInternal(ic.DAO, tokenID) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Get attribute attr, err := v.getAttributeInternal(ic.DAO, tokenID, key) if err != nil { panic(err) } if attr == nil { panic(ErrAttributeNotFound) } // Check if already revoked if attr.Revoked { panic(ErrAttributeRevoked) } // Check authorization: owner, attestor, or committee caller := ic.VM.GetCallingScriptHash() isOwner := caller.Equals(token.Owner) isAttestor := caller.Equals(attr.Attestor) isCommittee := v.checkCommittee(ic) if isOwner { ok, err := runtime.CheckHashedWitness(ic, token.Owner) if err != nil || !ok { panic(ErrVitaInvalidWitness) } } else if !isAttestor && !isCommittee { panic(ErrVitaInvalidWitness) } // Revoke attribute attr.Revoked = true attr.RevokedAt = ic.Block.Index // Store updated attribute if err := v.putAttribute(ic.DAO, tokenID, attr); err != nil { panic(err) } // Emit event err = ic.AddNotification(v.Hash, AttributeRevokedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(tokenID))), stackitem.NewByteArray([]byte(key)), stackitem.NewByteArray(caller.BytesBE()), stackitem.NewByteArray([]byte(reason)), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // verifyAttribute verifies that an attribute exists and its hash matches. // Returns true if the attribute exists, is not revoked, not expired, and hash matches. func (v *Vita) verifyAttribute(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() key := toString(args[1]) expectedHash := toBytes(args[2]) // Get attribute attr, err := v.getAttributeInternal(ic.DAO, tokenID, key) if err != nil { panic(err) } if attr == nil { return stackitem.NewBool(false) } // Check if revoked if attr.Revoked { return stackitem.NewBool(false) } // Check if expired (0 means never expires) if attr.ExpiresAt > 0 && ic.Block.Index > attr.ExpiresAt { return stackitem.NewBool(false) } // Compare hashes if len(attr.ValueHash) != len(expectedHash) { return stackitem.NewBool(false) } for i := range attr.ValueHash { if attr.ValueHash[i] != expectedHash[i] { return stackitem.NewBool(false) } } return stackitem.NewBool(true) } // Public methods for cross-native attribute access // GetAttribute returns an attribute for the given token and key (for cross-native access). func (v *Vita) GetAttribute(d *dao.Simple, tokenID uint64, key string) (*state.Attribute, error) { return v.getAttributeInternal(d, tokenID, key) } // Challenge storage methods func (v *Vita) putChallenge(d *dao.Simple, challenge *state.AuthChallenge) error { item, err := challenge.ToStackItem() if err != nil { return err } data, err := stackitem.Serialize(item) if err != nil { return err } d.PutStorageItem(v.ID, makeChallengeKey(challenge.ChallengeID), data) return nil } func (v *Vita) getChallengeInternal(d *dao.Simple, challengeID util.Uint256) (*state.AuthChallenge, error) { si := d.GetStorageItem(v.ID, makeChallengeKey(challengeID)) if si == nil { return nil, nil } item, err := stackitem.Deserialize(si) if err != nil { return nil, err } challenge := new(state.AuthChallenge) if err := challenge.FromStackItem(item); err != nil { return nil, err } return challenge, nil } func (v *Vita) setLastAuth(d *dao.Simple, tokenID uint64, purpose string, blockHeight uint32) { buf := make([]byte, 4) binary.BigEndian.PutUint32(buf, blockHeight) d.PutStorageItem(v.ID, makeLastAuthKey(tokenID, purpose), buf) } func (v *Vita) getLastAuth(d *dao.Simple, tokenID uint64, purpose string) uint32 { si := d.GetStorageItem(v.ID, makeLastAuthKey(tokenID, purpose)) if si == nil { return 0 } return binary.BigEndian.Uint32(si) } // Default challenge expiry in blocks (approximately 5 minutes at 15 seconds per block) const defaultChallengeExpiry uint32 = 20 // createChallenge creates a new authentication challenge for a token owner. // Anyone can create a challenge for any token owner. func (v *Vita) createChallenge(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) purpose := toString(args[1]) // Validate purpose if len(purpose) == 0 || len(purpose) > 32 { panic(ErrInvalidPurpose) } // Get token token, err := v.getTokenByOwnerInternal(ic.DAO, owner) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Check token is active if token.Status != state.TokenStatusActive { panic(ErrTokenNotActive) } // Generate nonce (use transaction hash + block index + token ID for uniqueness) nonceData := append(ic.Tx.Hash().BytesBE(), make([]byte, 8)...) binary.BigEndian.PutUint64(nonceData[util.Uint256Size:], token.TokenID) nonce := hash.Sha256(nonceData).BytesBE() // Generate challenge ID challengeIDData := append(nonce, owner.BytesBE()...) challengeIDData = append(challengeIDData, []byte(purpose)...) challengeID := hash.Sha256(challengeIDData) // Create challenge challenge := &state.AuthChallenge{ ChallengeID: challengeID, TokenID: token.TokenID, Nonce: nonce, CreatedAt: ic.Block.Index, ExpiresAt: ic.Block.Index + defaultChallengeExpiry, Purpose: purpose, Fulfilled: false, FulfilledAt: 0, } // Store challenge if err := v.putChallenge(ic.DAO, challenge); err != nil { panic(err) } // Emit event err = ic.AddNotification(v.Hash, AuthChallengeCreatedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewByteArray(challengeID.BytesBE()), stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))), stackitem.NewByteArray([]byte(purpose)), stackitem.NewBigInteger(big.NewInt(int64(challenge.ExpiresAt))), })) if err != nil { panic(err) } // Return challenge as struct item, err := challenge.ToStackItem() if err != nil { panic(err) } return item } // getChallenge returns a challenge by ID. func (v *Vita) getChallenge(ic *interop.Context, args []stackitem.Item) stackitem.Item { challengeIDBytes := toBytes(args[0]) if len(challengeIDBytes) != util.Uint256Size { panic("invalid challenge ID length") } challengeID, err := util.Uint256DecodeBytesBE(challengeIDBytes) if err != nil { panic(err) } challenge, err := v.getChallengeInternal(ic.DAO, challengeID) if err != nil { panic(err) } if challenge == nil { return stackitem.Null{} } item, err := challenge.ToStackItem() if err != nil { panic(err) } return item } // fulfillChallenge fulfills an authentication challenge by providing a valid signature. // The signature must be from the token owner over the challenge nonce. func (v *Vita) fulfillChallenge(ic *interop.Context, args []stackitem.Item) stackitem.Item { challengeIDBytes := toBytes(args[0]) if len(challengeIDBytes) != util.Uint256Size { panic("invalid challenge ID length") } challengeID, err := util.Uint256DecodeBytesBE(challengeIDBytes) if err != nil { panic(err) } _ = toBytes(args[1]) // signature - validation happens via witness // Get challenge challenge, err := v.getChallengeInternal(ic.DAO, challengeID) if err != nil { panic(err) } if challenge == nil { panic(ErrChallengeNotFound) } // Check if already fulfilled if challenge.Fulfilled { panic(ErrChallengeAlreadyFulfilled) } // Check if expired if ic.Block.Index > challenge.ExpiresAt { panic(ErrChallengeExpired) } // Get token token, err := v.getTokenByIDInternal(ic.DAO, challenge.TokenID) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Verify the caller has witness for the token owner // This ensures the owner is actually signing this transaction ok, err := runtime.CheckHashedWitness(ic, token.Owner) if err != nil || !ok { panic(ErrInvalidSignature) } // Mark challenge as fulfilled challenge.Fulfilled = true challenge.FulfilledAt = ic.Block.Index // Store updated challenge if err := v.putChallenge(ic.DAO, challenge); err != nil { panic(err) } // Record last auth time for this token and purpose v.setLastAuth(ic.DAO, challenge.TokenID, challenge.Purpose, ic.Block.Index) // Emit success event err = ic.AddNotification(v.Hash, AuthenticationSuccessEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(challenge.TokenID))), stackitem.NewByteArray([]byte(challenge.Purpose)), stackitem.NewBigInteger(big.NewInt(int64(ic.Block.Index))), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // verifyAuth checks if a token has authenticated recently for a given purpose. // Returns true if the last successful authentication for the purpose was within maxAge blocks. func (v *Vita) verifyAuth(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() purpose := toString(args[1]) maxAge := uint32(toBigInt(args[2]).Int64()) // Get last auth time lastAuth := v.getLastAuth(ic.DAO, tokenID, purpose) if lastAuth == 0 { return stackitem.NewBool(false) } // Check if within maxAge if ic.Block.Index > lastAuth+maxAge { return stackitem.NewBool(false) } return stackitem.NewBool(true) } // Public methods for cross-native authentication access // GetLastAuth returns the last authentication block height for a token and purpose. func (v *Vita) GetLastAuth(d *dao.Simple, tokenID uint64, purpose string) uint32 { return v.getLastAuth(d, tokenID, purpose) } // Recovery configuration constants const ( // defaultRecoveryDelay is the number of blocks before recovery can be executed (approximately 24 hours at 15s blocks) defaultRecoveryDelay uint32 = 5760 // defaultRecoveryExpiry is the number of blocks until a recovery request expires (approximately 7 days) defaultRecoveryExpiry uint32 = 40320 // defaultRequiredApprovals is the number of committee approvals needed for recovery defaultRequiredApprovals int = 2 ) // Recovery storage methods func (v *Vita) putRecoveryRequest(d *dao.Simple, req *state.RecoveryRequest) error { item, err := req.ToStackItem() if err != nil { return err } data, err := stackitem.Serialize(item) if err != nil { return err } d.PutStorageItem(v.ID, makeRecoveryKey(req.RequestID), data) return nil } func (v *Vita) getRecoveryRequestInternal(d *dao.Simple, requestID util.Uint256) (*state.RecoveryRequest, error) { si := d.GetStorageItem(v.ID, makeRecoveryKey(requestID)) if si == nil { return nil, nil } item, err := stackitem.Deserialize(si) if err != nil { return nil, err } req := new(state.RecoveryRequest) if err := req.FromStackItem(item); err != nil { return nil, err } return req, nil } func (v *Vita) setActiveRecovery(d *dao.Simple, tokenID uint64, requestID util.Uint256) { d.PutStorageItem(v.ID, makeActiveRecoveryKey(tokenID), requestID.BytesBE()) } func (v *Vita) getActiveRecovery(d *dao.Simple, tokenID uint64) *util.Uint256 { si := d.GetStorageItem(v.ID, makeActiveRecoveryKey(tokenID)) if si == nil { return nil } requestID, err := util.Uint256DecodeBytesBE(si) if err != nil { return nil } return &requestID } func (v *Vita) clearActiveRecovery(d *dao.Simple, tokenID uint64) { d.DeleteStorageItem(v.ID, makeActiveRecoveryKey(tokenID)) } // initiateRecovery starts a key recovery process for a token. // Anyone can initiate recovery for any token. func (v *Vita) initiateRecovery(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() newOwner := toUint160(args[1]) evidence := toBytes(args[2]) // Validate new owner if newOwner.Equals(util.Uint160{}) { panic(ErrInvalidNewOwner) } // Get token token, err := v.getTokenByIDInternal(ic.DAO, tokenID) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Check token is not revoked if token.Status == state.TokenStatusRevoked { panic(ErrTokenRevoked) } // Check token is not already in recovery if token.Status == state.TokenStatusRecovering { panic(ErrTokenInRecovery) } // Check no active recovery exists if v.getActiveRecovery(ic.DAO, tokenID) != nil { panic(ErrRecoveryAlreadyActive) } // Get requester (caller) requester := ic.VM.GetCallingScriptHash() // Generate request ID requestIDData := append(ic.Tx.Hash().BytesBE(), make([]byte, 8)...) binary.BigEndian.PutUint64(requestIDData[util.Uint256Size:], tokenID) requestIDData = append(requestIDData, newOwner.BytesBE()...) requestID := hash.Sha256(requestIDData) // Create recovery request req := &state.RecoveryRequest{ RequestID: requestID, TokenID: tokenID, NewOwner: newOwner, Requester: requester, Evidence: evidence, Approvals: []util.Uint160{}, RequiredApprovals: defaultRequiredApprovals, CreatedAt: ic.Block.Index, DelayUntil: ic.Block.Index + defaultRecoveryDelay, ExpiresAt: ic.Block.Index + defaultRecoveryExpiry, Status: state.RecoveryStatusPending, } // Store recovery request if err := v.putRecoveryRequest(ic.DAO, req); err != nil { panic(err) } // Mark active recovery for token v.setActiveRecovery(ic.DAO, tokenID, requestID) // Update token status to recovering token.Status = state.TokenStatusRecovering token.StatusReason = "recovery initiated" token.UpdatedAt = ic.Block.Index if err := v.putToken(ic.DAO, token); err != nil { panic(err) } // Emit event err = ic.AddNotification(v.Hash, RecoveryInitiatedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(tokenID))), stackitem.NewByteArray(requestID.BytesBE()), stackitem.NewBigInteger(big.NewInt(int64(req.DelayUntil))), })) if err != nil { panic(err) } return stackitem.NewByteArray(requestID.BytesBE()) } // approveRecovery approves a recovery request (committee member only). func (v *Vita) approveRecovery(ic *interop.Context, args []stackitem.Item) stackitem.Item { requestIDBytes := toBytes(args[0]) if len(requestIDBytes) != util.Uint256Size { panic("invalid request ID length") } requestID, err := util.Uint256DecodeBytesBE(requestIDBytes) if err != nil { panic(err) } // Check committee if !v.checkCommittee(ic) { panic(ErrNotCommittee) } // Get recovery request req, err := v.getRecoveryRequestInternal(ic.DAO, requestID) if err != nil { panic(err) } if req == nil { panic(ErrRecoveryNotFound) } // Check status is pending if req.Status != state.RecoveryStatusPending { panic(ErrRecoveryNotPending) } // Check not expired if ic.Block.Index > req.ExpiresAt { panic(ErrRecoveryExpired) } // Get approver (caller) approver := ic.VM.GetCallingScriptHash() // Check not already approved by this approver for _, a := range req.Approvals { if a.Equals(approver) { panic(ErrAlreadyApproved) } } // Add approval req.Approvals = append(req.Approvals, approver) // Check if sufficient approvals if len(req.Approvals) >= req.RequiredApprovals { req.Status = state.RecoveryStatusApproved } // Store updated request if err := v.putRecoveryRequest(ic.DAO, req); err != nil { panic(err) } // Emit event err = ic.AddNotification(v.Hash, RecoveryApprovalEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewByteArray(requestID.BytesBE()), stackitem.NewByteArray(approver.BytesBE()), stackitem.NewBigInteger(big.NewInt(int64(len(req.Approvals)))), stackitem.NewBigInteger(big.NewInt(int64(req.RequiredApprovals))), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // executeRecovery executes an approved recovery request after the delay period. func (v *Vita) executeRecovery(ic *interop.Context, args []stackitem.Item) stackitem.Item { requestIDBytes := toBytes(args[0]) if len(requestIDBytes) != util.Uint256Size { panic("invalid request ID length") } requestID, err := util.Uint256DecodeBytesBE(requestIDBytes) if err != nil { panic(err) } // Get recovery request req, err := v.getRecoveryRequestInternal(ic.DAO, requestID) if err != nil { panic(err) } if req == nil { panic(ErrRecoveryNotFound) } // Check status is approved if req.Status != state.RecoveryStatusApproved { panic(ErrRecoveryNotPending) } // Check delay period has passed if ic.Block.Index < req.DelayUntil { panic(ErrRecoveryDelayNotPassed) } // Check not expired if ic.Block.Index > req.ExpiresAt { panic(ErrRecoveryExpired) } // Get token token, err := v.getTokenByIDInternal(ic.DAO, req.TokenID) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Store old owner for event oldOwner := token.Owner // Delete old owner -> tokenID mapping ic.DAO.DeleteStorageItem(v.ID, makeTokenByOwnerKey(oldOwner)) // Update token owner token.Owner = req.NewOwner token.Status = state.TokenStatusActive token.StatusReason = "" token.UpdatedAt = ic.Block.Index // Store updated token (this will create new owner -> tokenID mapping) if err := v.putToken(ic.DAO, token); err != nil { panic(err) } // Update request status req.Status = state.RecoveryStatusExecuted if err := v.putRecoveryRequest(ic.DAO, req); err != nil { panic(err) } // Clear active recovery v.clearActiveRecovery(ic.DAO, req.TokenID) // Emit event err = ic.AddNotification(v.Hash, RecoveryExecutedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(req.TokenID))), stackitem.NewByteArray(oldOwner.BytesBE()), stackitem.NewByteArray(req.NewOwner.BytesBE()), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // cancelRecovery cancels an active recovery request. // Can be called by the original token owner or the requester. func (v *Vita) cancelRecovery(ic *interop.Context, args []stackitem.Item) stackitem.Item { requestIDBytes := toBytes(args[0]) if len(requestIDBytes) != util.Uint256Size { panic("invalid request ID length") } requestID, err := util.Uint256DecodeBytesBE(requestIDBytes) if err != nil { panic(err) } // Get recovery request req, err := v.getRecoveryRequestInternal(ic.DAO, requestID) if err != nil { panic(err) } if req == nil { panic(ErrRecoveryNotFound) } // Check status is pending or approved (not executed or denied) if req.Status == state.RecoveryStatusExecuted || req.Status == state.RecoveryStatusDenied { panic(ErrRecoveryNotPending) } // Get token token, err := v.getTokenByIDInternal(ic.DAO, req.TokenID) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Check authorization: owner, requester, or committee caller := ic.VM.GetCallingScriptHash() isOwner := caller.Equals(token.Owner) isRequester := caller.Equals(req.Requester) isCommittee := v.checkCommittee(ic) if isOwner { ok, err := runtime.CheckHashedWitness(ic, token.Owner) if err != nil || !ok { panic(ErrVitaInvalidWitness) } } else if !isRequester && !isCommittee { panic(ErrVitaInvalidWitness) } // Update request status req.Status = state.RecoveryStatusDenied if err := v.putRecoveryRequest(ic.DAO, req); err != nil { panic(err) } // Restore token status token.Status = state.TokenStatusActive token.StatusReason = "" token.UpdatedAt = ic.Block.Index if err := v.putToken(ic.DAO, token); err != nil { panic(err) } // Clear active recovery v.clearActiveRecovery(ic.DAO, req.TokenID) // Emit event err = ic.AddNotification(v.Hash, RecoveryCancelledEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewByteArray(requestID.BytesBE()), stackitem.NewByteArray(caller.BytesBE()), })) if err != nil { panic(err) } return stackitem.NewBool(true) } // getRecoveryRequest returns a recovery request by ID. func (v *Vita) getRecoveryRequest(ic *interop.Context, args []stackitem.Item) stackitem.Item { requestIDBytes := toBytes(args[0]) if len(requestIDBytes) != util.Uint256Size { panic("invalid request ID length") } requestID, err := util.Uint256DecodeBytesBE(requestIDBytes) if err != nil { panic(err) } req, err := v.getRecoveryRequestInternal(ic.DAO, requestID) if err != nil { panic(err) } if req == nil { return stackitem.Null{} } item, err := req.ToStackItem() if err != nil { panic(err) } return item } // Public methods for cross-native recovery access // GetRecoveryRequest returns a recovery request by ID (for cross-native access). func (v *Vita) GetRecoveryRequest(d *dao.Simple, requestID util.Uint256) (*state.RecoveryRequest, error) { return v.getRecoveryRequestInternal(d, requestID) } // GetActiveRecoveryForToken returns the active recovery request ID for a token (for cross-native access). func (v *Vita) GetActiveRecoveryForToken(d *dao.Simple, tokenID uint64) *util.Uint256 { return v.getActiveRecovery(d, tokenID) } // Cross-contract integration methods // validateCaller validates that the calling contract's owner has a valid Vita. // Returns the tokenID and core roles for the caller. func (v *Vita) validateCaller(ic *interop.Context, args []stackitem.Item) stackitem.Item { caller := ic.VM.GetCallingScriptHash() // Get token by caller address token, err := v.getTokenByOwnerInternal(ic.DAO, caller) if err != nil { panic(err) } if token == nil { panic(ErrCallerHasNoVita) } // Check token is active if token.Status != state.TokenStatusActive { if token.Status == state.TokenStatusSuspended { panic(ErrTokenSuspended) } if token.Status == state.TokenStatusRevoked { panic(ErrTokenRevoked) } if token.Status == state.TokenStatusRecovering { panic(ErrTokenInRecovery) } panic(ErrTokenNotActive) } // Determine core roles roles := v.getCoreRoles(ic, token) // Return struct with tokenID and roles return stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))), stackitem.NewBigInteger(big.NewInt(int64(roles))), }) } // getCoreRoles determines the core roles for a token holder. func (v *Vita) getCoreRoles(ic *interop.Context, token *state.Vita) uint64 { var roles uint64 // All active token holders have User role roles |= 1 << uint64(CoreRoleUser) // Check if user is verified (has non-expired, non-revoked attributes) if v.hasVerifiedAttributes(ic.DAO, token.TokenID) { roles |= 1 << uint64(CoreRoleVerified) } // Check if user is a committee member if v.checkCommittee(ic) { roles |= 1 << uint64(CoreRoleCommittee) roles |= 1 << uint64(CoreRoleAttestor) // Committee members can attest roles |= 1 << uint64(CoreRoleRecovery) // Committee members participate in recovery } return roles } // hasVerifiedAttributes checks if a token has any valid (non-expired, non-revoked) attributes. func (v *Vita) hasVerifiedAttributes(d *dao.Simple, tokenID uint64) bool { // For now, just check if any attribute exists // In a full implementation, this would scan attributes and check expiry/revocation prefix := make([]byte, 9) prefix[0] = prefixAttribute binary.BigEndian.PutUint64(prefix[1:], tokenID) // Check if any attribute storage items exist for this token var found bool d.Seek(v.ID, storage.SeekRange{Prefix: prefix}, func(k, v []byte) bool { found = true return false // Stop after finding first }) return found } // requireRole checks if the caller has a specific role ID (for RoleRegistry integration). // This is a stub that will be extended when RoleRegistry is implemented. func (v *Vita) requireRole(ic *interop.Context, args []stackitem.Item) stackitem.Item { roleID := toBigInt(args[0]).Uint64() caller := ic.VM.GetCallingScriptHash() // Get token by caller address token, err := v.getTokenByOwnerInternal(ic.DAO, caller) if err != nil { panic(err) } if token == nil { panic(ErrCallerHasNoVita) } // Check token is active if token.Status != state.TokenStatusActive { panic(ErrTokenNotActive) } // Stub: In the future, this will check against RoleRegistry // For now, just validate the token exists and is active // Role 0 means "any authenticated user" if roleID == 0 { return stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))) } // For non-zero roles, check if it maps to a core role if roleID <= uint64(CoreRoleRecovery) { roles := v.getCoreRoles(ic, token) if roles&(1< CoreRoleRecovery { panic(ErrVitaInvalidRole) } caller := ic.VM.GetCallingScriptHash() // Get token by caller address token, err := v.getTokenByOwnerInternal(ic.DAO, caller) if err != nil { panic(err) } if token == nil { panic(ErrCallerHasNoVita) } // Check token is active if token.Status != state.TokenStatusActive { panic(ErrTokenNotActive) } // CoreRoleNone means any authenticated user if coreRole == CoreRoleNone { return stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))) } // Check if user has the required core role roles := v.getCoreRoles(ic, token) if roles&(1<= vestedUntil. func (v *Vita) isFullyVested(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() token, err := v.getTokenByIDInternal(ic.DAO, tokenID) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Token is fully vested if current block >= vestedUntil // VestedUntil of 0 means immediately vested (legacy tokens) isVested := token.VestedUntil == 0 || ic.Block.Index >= token.VestedUntil return stackitem.NewBool(isVested) } // getVestingInfo returns vesting information for a Vita token. // Returns [vestedUntil, isFullyVested, remainingBlocks] or null if token not found. func (v *Vita) getVestingInfo(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() token, err := v.getTokenByIDInternal(ic.DAO, tokenID) if err != nil { panic(err) } if token == nil { return stackitem.Null{} } isVested := token.VestedUntil == 0 || ic.Block.Index >= token.VestedUntil var remainingBlocks uint32 if !isVested && token.VestedUntil > ic.Block.Index { remainingBlocks = token.VestedUntil - ic.Block.Index } return stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(token.VestedUntil))), stackitem.NewBool(isVested), stackitem.NewBigInteger(big.NewInt(int64(remainingBlocks))), }) } // Public methods for cross-native vesting access // IsFullyVestedInternal checks if a Vita token has completed its vesting period. // For cross-contract use by other native contracts like Collocatio and Eligere. func (v *Vita) IsFullyVestedInternal(d *dao.Simple, tokenID uint64, currentBlock uint32) bool { token, err := v.getTokenByIDInternal(d, tokenID) if err != nil || token == nil { return false } // VestedUntil of 0 means immediately vested (legacy tokens) return token.VestedUntil == 0 || currentBlock >= token.VestedUntil } // GetVestedUntil returns the vesting block height for a Vita token. // Returns 0 if token not found. func (v *Vita) GetVestedUntil(d *dao.Simple, tokenID uint64) uint32 { token, err := v.getTokenByIDInternal(d, tokenID) if err != nil || token == nil { return 0 } return token.VestedUntil } // ============================================================================ // VPP (Vita Presence Protocol) Implementation // ============================================================================ // presenceKey generates the storage key for a presence record. func (v *Vita) presenceKey(tokenID uint64) []byte { key := make([]byte, 9) key[0] = prefixPresence binary.BigEndian.PutUint64(key[1:], tokenID) return key } // putPresence stores a presence record. func (v *Vita) putPresence(d *dao.Simple, record *state.PresenceRecord) error { key := v.presenceKey(record.TokenID) item, err := record.ToStackItem() if err != nil { return err } data, err := stackitem.Serialize(item) if err != nil { return err } d.PutStorageItem(v.ID, key, data) return nil } // getPresenceInternal retrieves a presence record. func (v *Vita) getPresenceInternal(d *dao.Simple, tokenID uint64) (*state.PresenceRecord, error) { key := v.presenceKey(tokenID) si := d.GetStorageItem(v.ID, key) if si == nil { return nil, ErrPresenceNotFound } item, err := stackitem.Deserialize(si) if err != nil { return nil, err } record := &state.PresenceRecord{} if err := record.FromStackItem(item); err != nil { return nil, err } return record, nil } // getPresenceConfigInternal retrieves or creates default presence config. func (v *Vita) getPresenceConfigInternal(d *dao.Simple) state.PresenceConfig { key := []byte{prefixPresenceConfig} si := d.GetStorageItem(v.ID, key) if si == nil { return state.DefaultPresenceConfig() } item, err := stackitem.Deserialize(si) if err != nil { return state.DefaultPresenceConfig() } config := state.PresenceConfig{} if err := config.FromStackItem(item); err != nil { return state.DefaultPresenceConfig() } return config } // computePresenceStatus computes the current presence status based on last heartbeat. func (v *Vita) computePresenceStatus(d *dao.Simple, tokenID uint64, currentBlock uint32) state.PresenceStatus { record, err := v.getPresenceInternal(d, tokenID) if err != nil { return state.PresenceStatusOffline } // If invisible mode, return invisible if record.Invisible { return state.PresenceStatusInvisible } config := v.getPresenceConfigInternal(d) blocksSinceHeartbeat := currentBlock - record.LastHeartbeat if blocksSinceHeartbeat <= config.PresenceWindow { return state.PresenceStatusOnline } else if blocksSinceHeartbeat <= config.AwayWindow { return state.PresenceStatusAway } return state.PresenceStatusOffline } // recordPresence records a heartbeat for the caller's Vita. // This is the core VPP method called by the Symbey app. func (v *Vita) recordPresence(ic *interop.Context, args []stackitem.Item) stackitem.Item { sessionID, err := args[0].TryBytes() if err != nil { panic(fmt.Errorf("invalid sessionId: %w", err)) } if len(sessionID) != 32 { panic(ErrPresenceInvalidSession) } deviceHash, err := args[1].TryBytes() if err != nil { panic(fmt.Errorf("invalid deviceHash: %w", err)) } if len(deviceHash) != 32 { panic(ErrPresenceInvalidDevice) } // Get caller's Vita caller := ic.VM.GetCallingScriptHash() token, err := v.getTokenByOwnerInternal(ic.DAO, caller) if err != nil || token == nil { panic(ErrCallerHasNoVita) } if token.Status != state.TokenStatusActive { panic(ErrTokenNotActive) } // Check rate limiting config := v.getPresenceConfigInternal(ic.DAO) existing, _ := v.getPresenceInternal(ic.DAO, token.TokenID) if existing != nil { blocksSinceLastHeartbeat := ic.Block.Index - existing.LastHeartbeat if blocksSinceLastHeartbeat < config.MinHeartbeatInterval { panic(ErrPresenceRateLimited) } } // Create or update presence record record := &state.PresenceRecord{ TokenID: token.TokenID, LastHeartbeat: ic.Block.Index, SessionID: sessionID, DeviceHash: deviceHash, Status: state.PresenceStatusOnline, Invisible: existing != nil && existing.Invisible, } if err := v.putPresence(ic.DAO, record); err != nil { panic(err) } // Emit event ic.AddNotification(v.Hash, PresenceRecordedEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))), stackitem.NewBigInteger(big.NewInt(int64(ic.Block.Index))), stackitem.NewByteArray(sessionID), })) return stackitem.NewBool(true) } // getPresence retrieves the presence record for a token. func (v *Vita) getPresence(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID, err := args[0].TryInteger() if err != nil { panic(fmt.Errorf("invalid tokenId: %w", err)) } record, err := v.getPresenceInternal(ic.DAO, tokenID.Uint64()) if err != nil { return stackitem.Null{} } // Compute current status based on time since last heartbeat record.Status = v.computePresenceStatus(ic.DAO, record.TokenID, ic.Block.Index) item, err := record.ToStackItem() if err != nil { panic(err) } return item } // getPresenceStatus returns the computed presence status for a token. func (v *Vita) getPresenceStatus(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID, err := args[0].TryInteger() if err != nil { panic(fmt.Errorf("invalid tokenId: %w", err)) } status := v.computePresenceStatus(ic.DAO, tokenID.Uint64(), ic.Block.Index) return stackitem.NewBigInteger(big.NewInt(int64(status))) } // setPresenceVisibility sets the caller's presence visibility (invisible mode). func (v *Vita) setPresenceVisibility(ic *interop.Context, args []stackitem.Item) stackitem.Item { invisible, err := args[0].TryBool() if err != nil { panic(fmt.Errorf("invalid invisible: %w", err)) } // Get caller's Vita caller := ic.VM.GetCallingScriptHash() token, err := v.getTokenByOwnerInternal(ic.DAO, caller) if err != nil || token == nil { panic(ErrCallerHasNoVita) } // Get or create presence record record, _ := v.getPresenceInternal(ic.DAO, token.TokenID) if record == nil { record = &state.PresenceRecord{ TokenID: token.TokenID, LastHeartbeat: 0, SessionID: make([]byte, 32), DeviceHash: make([]byte, 32), Status: state.PresenceStatusOffline, } } record.Invisible = invisible if invisible { record.Status = state.PresenceStatusInvisible } if err := v.putPresence(ic.DAO, record); err != nil { panic(err) } // Emit event ic.AddNotification(v.Hash, PresenceVisibilityEvent, stackitem.NewArray([]stackitem.Item{ stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))), stackitem.NewBool(invisible), })) return stackitem.NewBool(true) } // getPresenceConfig returns the VPP configuration. func (v *Vita) getPresenceConfig(ic *interop.Context, _ []stackitem.Item) stackitem.Item { config := v.getPresenceConfigInternal(ic.DAO) item, err := config.ToStackItem() if err != nil { panic(err) } return item } // ============================================================================ // VPP Public Internal Methods (for cross-contract access) // ============================================================================ // GetPresenceRecord returns the presence record for a token (internal use). func (v *Vita) GetPresenceRecord(d *dao.Simple, tokenID uint64) (*state.PresenceRecord, error) { return v.getPresenceInternal(d, tokenID) } // GetPresenceStatusInternal returns the computed presence status (internal use). func (v *Vita) GetPresenceStatusInternal(d *dao.Simple, tokenID uint64, currentBlock uint32) state.PresenceStatus { return v.computePresenceStatus(d, tokenID, currentBlock) } // IsOnline checks if a Vita holder is currently online. func (v *Vita) IsOnline(d *dao.Simple, tokenID uint64, currentBlock uint32) bool { status := v.computePresenceStatus(d, tokenID, currentBlock) return status == state.PresenceStatusOnline } // IsPresent checks if a Vita holder is present (online or away, not offline). func (v *Vita) IsPresent(d *dao.Simple, tokenID uint64, currentBlock uint32) bool { status := v.computePresenceStatus(d, tokenID, currentBlock) return status == state.PresenceStatusOnline || status == state.PresenceStatusAway }