package native import ( "encoding/binary" "errors" "fmt" "math/big" "github.com/tutus-one/tutus-chain/pkg/config" "github.com/tutus-one/tutus-chain/pkg/core/dao" "github.com/tutus-one/tutus-chain/pkg/core/interop" "github.com/tutus-one/tutus-chain/pkg/core/interop/runtime" "github.com/tutus-one/tutus-chain/pkg/core/native/nativeids" "github.com/tutus-one/tutus-chain/pkg/core/native/nativenames" "github.com/tutus-one/tutus-chain/pkg/core/state" "github.com/tutus-one/tutus-chain/pkg/core/storage" "github.com/tutus-one/tutus-chain/pkg/crypto/hash" "github.com/tutus-one/tutus-chain/pkg/smartcontract" "github.com/tutus-one/tutus-chain/pkg/smartcontract/callflag" "github.com/tutus-one/tutus-chain/pkg/smartcontract/manifest" "github.com/tutus-one/tutus-chain/pkg/util" "github.com/tutus-one/tutus-chain/pkg/vm/stackitem" ) // PersonToken represents a soul-bound identity native contract. type PersonToken struct { interop.ContractMD NEO INEO } // PersonTokenCache represents the cached state for PersonToken contract. type PersonTokenCache struct { tokenCount uint64 } // Storage key prefixes for PersonToken. const ( prefixTokenByOwner = 0x01 // owner (Uint160) -> tokenID (uint64) prefixTokenByID = 0x02 // tokenID (uint64) -> PersonToken prefixPersonHash = 0x03 // personHash -> tokenID (uniqueness) prefixAttribute = 0x04 // tokenID + attrKey -> Attribute prefixChallenge = 0x05 // challengeID -> AuthChallenge prefixRecovery = 0x06 // requestID -> RecoveryRequest prefixActiveRecovery = 0x07 // tokenID + "recovery" -> requestID prefixTokenCounter = 0x10 // -> uint64 prefixConfig = 0x11 // -> PersonTokenConfig ) // Event names for PersonToken. const ( PersonTokenCreatedEvent = "PersonTokenCreated" PersonTokenSuspendedEvent = "PersonTokenSuspended" PersonTokenReinstatedEvent = "PersonTokenReinstated" PersonTokenRevokedEvent = "PersonTokenRevoked" AttributeSetEvent = "AttributeSet" AttributeRevokedEvent = "AttributeRevoked" AuthenticationSuccessEvent = "AuthenticationSuccess" AuthChallengeCreatedEvent = "AuthChallengeCreated" RecoveryInitiatedEvent = "RecoveryInitiated" RecoveryApprovalEvent = "RecoveryApproval" RecoveryExecutedEvent = "RecoveryExecuted" RecoveryCancelledEvent = "RecoveryCancelled" ) // 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") ErrPersonTokenInvalidWitness = errors.New("invalid witness") 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") ErrCallerHasNoToken = errors.New("caller does not have a PersonToken") ErrRoleNotAssigned = errors.New("required role not assigned") ErrPermissionDenied = errors.New("permission denied") ErrPersonTokenInvalidRole = errors.New("invalid core role") ErrPersonTokenInvalidResource = errors.New("invalid resource") ErrPersonTokenInvalidAction = errors.New("invalid action") ) // CoreRole represents built-in roles for cross-contract access control. type CoreRole uint8 // Core roles for PersonToken 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 = (*PersonToken)(nil) _ dao.NativeContractCache = (*PersonTokenCache)(nil) ) // Copy implements NativeContractCache interface. func (c *PersonTokenCache) Copy() dao.NativeContractCache { return &PersonTokenCache{ tokenCount: c.tokenCount, } } // newPersonToken creates a new PersonToken native contract. func newPersonToken() *PersonToken { p := &PersonToken{ ContractMD: *interop.NewContractMD(nativenames.PersonToken, nativeids.PersonToken), } defer p.BuildHFSpecificMD(p.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)) md := NewMethodAndPrice(p.register, 1<<17, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // GetToken method desc = NewDescriptor("getToken", smartcontract.ArrayType, manifest.NewParameter("owner", smartcontract.Hash160Type)) md = NewMethodAndPrice(p.getToken, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // GetTokenByID method desc = NewDescriptor("getTokenByID", smartcontract.ArrayType, manifest.NewParameter("tokenId", smartcontract.IntegerType)) md = NewMethodAndPrice(p.getTokenByID, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // Exists method desc = NewDescriptor("exists", smartcontract.BoolType, manifest.NewParameter("owner", smartcontract.Hash160Type)) md = NewMethodAndPrice(p.exists, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // TotalSupply method desc = NewDescriptor("totalSupply", smartcontract.IntegerType) md = NewMethodAndPrice(p.totalSupply, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // Suspend method desc = NewDescriptor("suspend", smartcontract.BoolType, manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("reason", smartcontract.StringType)) md = NewMethodAndPrice(p.suspend, 1<<16, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // Reinstate method desc = NewDescriptor("reinstate", smartcontract.BoolType, manifest.NewParameter("owner", smartcontract.Hash160Type)) md = NewMethodAndPrice(p.reinstate, 1<<16, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // Revoke method desc = NewDescriptor("revoke", smartcontract.BoolType, manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("reason", smartcontract.StringType)) md = NewMethodAndPrice(p.revoke, 1<<17, callflag.States|callflag.AllowNotify) p.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(p.setAttribute, 1<<17, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // GetAttribute method desc = NewDescriptor("getAttribute", smartcontract.ArrayType, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("key", smartcontract.StringType)) md = NewMethodAndPrice(p.getAttribute, 1<<15, callflag.ReadStates) p.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(p.revokeAttribute, 1<<16, callflag.States|callflag.AllowNotify) p.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(p.verifyAttribute, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // CreateChallenge method desc = NewDescriptor("createChallenge", smartcontract.ArrayType, manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("purpose", smartcontract.StringType)) md = NewMethodAndPrice(p.createChallenge, 1<<16, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // GetChallenge method desc = NewDescriptor("getChallenge", smartcontract.ArrayType, manifest.NewParameter("challengeId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(p.getChallenge, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // FulfillChallenge method desc = NewDescriptor("fulfillChallenge", smartcontract.BoolType, manifest.NewParameter("challengeId", smartcontract.ByteArrayType), manifest.NewParameter("signature", smartcontract.ByteArrayType)) md = NewMethodAndPrice(p.fulfillChallenge, 1<<17, callflag.States|callflag.AllowNotify) p.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(p.verifyAuth, 1<<15, callflag.ReadStates) p.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(p.initiateRecovery, 1<<17, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // ApproveRecovery method desc = NewDescriptor("approveRecovery", smartcontract.BoolType, manifest.NewParameter("requestId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(p.approveRecovery, 1<<16, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // ExecuteRecovery method desc = NewDescriptor("executeRecovery", smartcontract.BoolType, manifest.NewParameter("requestId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(p.executeRecovery, 1<<17, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // CancelRecovery method desc = NewDescriptor("cancelRecovery", smartcontract.BoolType, manifest.NewParameter("requestId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(p.cancelRecovery, 1<<16, callflag.States|callflag.AllowNotify) p.AddMethod(md, desc) // GetRecoveryRequest method desc = NewDescriptor("getRecoveryRequest", smartcontract.ArrayType, manifest.NewParameter("requestId", smartcontract.ByteArrayType)) md = NewMethodAndPrice(p.getRecoveryRequest, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // ValidateCaller method desc = NewDescriptor("validateCaller", smartcontract.ArrayType) md = NewMethodAndPrice(p.validateCaller, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // RequireRole method desc = NewDescriptor("requireRole", smartcontract.IntegerType, manifest.NewParameter("roleId", smartcontract.IntegerType)) md = NewMethodAndPrice(p.requireRole, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // RequireCoreRole method desc = NewDescriptor("requireCoreRole", smartcontract.IntegerType, manifest.NewParameter("coreRole", smartcontract.IntegerType)) md = NewMethodAndPrice(p.requireCoreRole, 1<<15, callflag.ReadStates) p.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(p.requirePermission, 1<<15, callflag.ReadStates) p.AddMethod(md, desc) // Events eDesc := NewEventDescriptor(PersonTokenCreatedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), manifest.NewParameter("owner", smartcontract.Hash160Type), manifest.NewParameter("createdAt", smartcontract.IntegerType)) p.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(PersonTokenSuspendedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), manifest.NewParameter("reason", smartcontract.StringType), manifest.NewParameter("suspendedBy", smartcontract.Hash160Type)) p.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(PersonTokenReinstatedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), manifest.NewParameter("reinstatedBy", smartcontract.Hash160Type)) p.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(PersonTokenRevokedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), manifest.NewParameter("reason", smartcontract.StringType), manifest.NewParameter("revokedBy", smartcontract.Hash160Type)) p.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)) p.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)) p.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)) p.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(AuthenticationSuccessEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("purpose", smartcontract.StringType), manifest.NewParameter("timestamp", smartcontract.IntegerType)) p.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(RecoveryInitiatedEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("requestId", smartcontract.ByteArrayType), manifest.NewParameter("delayUntil", smartcontract.IntegerType)) p.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)) p.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(RecoveryExecutedEvent, manifest.NewParameter("tokenId", smartcontract.IntegerType), manifest.NewParameter("oldOwner", smartcontract.Hash160Type), manifest.NewParameter("newOwner", smartcontract.Hash160Type)) p.AddEvent(NewEvent(eDesc)) eDesc = NewEventDescriptor(RecoveryCancelledEvent, manifest.NewParameter("requestId", smartcontract.ByteArrayType), manifest.NewParameter("cancelledBy", smartcontract.Hash160Type)) p.AddEvent(NewEvent(eDesc)) return p } // Metadata returns contract metadata. func (p *PersonToken) Metadata() *interop.ContractMD { return &p.ContractMD } // Initialize initializes PersonToken contract at the specified hardfork. func (p *PersonToken) Initialize(ic *interop.Context, hf *config.Hardfork, newMD *interop.HFSpecificContractMD) error { if hf != p.ActiveIn() { return nil } cache := &PersonTokenCache{ tokenCount: 0, } ic.DAO.SetCache(p.ID, cache) // Initialize token counter to 0 p.setTokenCounter(ic.DAO, 0) return nil } // InitializeCache fills native PersonToken cache from DAO on node restart. func (p *PersonToken) InitializeCache(_ interop.IsHardforkEnabled, blockHeight uint32, d *dao.Simple) error { cache := &PersonTokenCache{} // Load token counter from storage counter := p.getTokenCounter(d) cache.tokenCount = counter d.SetCache(p.ID, cache) return nil } // OnPersist implements the Contract interface. func (p *PersonToken) OnPersist(ic *interop.Context) error { return nil } // PostPersist implements the Contract interface. func (p *PersonToken) PostPersist(ic *interop.Context) error { return nil } // ActiveIn returns the hardfork this contract activates in. // PersonToken is always active (returns nil). func (p *PersonToken) 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 (p *PersonToken) getTokenCounter(d *dao.Simple) uint64 { si := d.GetStorageItem(p.ID, []byte{prefixTokenCounter}) if si == nil { return 0 } return binary.BigEndian.Uint64(si) } func (p *PersonToken) setTokenCounter(d *dao.Simple, count uint64) { buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, count) d.PutStorageItem(p.ID, []byte{prefixTokenCounter}, buf) } func (p *PersonToken) getAndIncrementTokenCounter(d *dao.Simple) uint64 { cache := d.GetRWCache(p.ID).(*PersonTokenCache) tokenID := cache.tokenCount cache.tokenCount++ p.setTokenCounter(d, cache.tokenCount) return tokenID } // Token storage methods func (p *PersonToken) putToken(d *dao.Simple, token *state.PersonToken) 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(p.ID, makeTokenByIDKey(token.TokenID), data) // Store owner -> tokenID mapping tokenIDBytes := make([]byte, 8) binary.BigEndian.PutUint64(tokenIDBytes, token.TokenID) d.PutStorageItem(p.ID, makeTokenByOwnerKey(token.Owner), tokenIDBytes) // Store personHash -> tokenID mapping for uniqueness if len(token.PersonHash) > 0 { d.PutStorageItem(p.ID, makePersonHashKey(token.PersonHash), tokenIDBytes) } return nil } func (p *PersonToken) getTokenByOwnerInternal(d *dao.Simple, owner util.Uint160) (*state.PersonToken, error) { si := d.GetStorageItem(p.ID, makeTokenByOwnerKey(owner)) if si == nil { return nil, nil } tokenID := binary.BigEndian.Uint64(si) return p.getTokenByIDInternal(d, tokenID) } func (p *PersonToken) getTokenByIDInternal(d *dao.Simple, tokenID uint64) (*state.PersonToken, error) { si := d.GetStorageItem(p.ID, makeTokenByIDKey(tokenID)) if si == nil { return nil, nil } item, err := stackitem.Deserialize(si) if err != nil { return nil, err } token := new(state.PersonToken) if err := token.FromStackItem(item); err != nil { return nil, err } return token, nil } func (p *PersonToken) tokenExistsForOwner(d *dao.Simple, owner util.Uint160) bool { si := d.GetStorageItem(p.ID, makeTokenByOwnerKey(owner)) return si != nil } func (p *PersonToken) personHashExists(d *dao.Simple, personHash []byte) bool { if len(personHash) == 0 { return false } si := d.GetStorageItem(p.ID, makePersonHashKey(personHash)) return si != nil } // Contract methods // register creates a new PersonToken. func (p *PersonToken) 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]) // 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(ErrPersonTokenInvalidWitness) } // Check if token already exists for this owner if p.tokenExistsForOwner(ic.DAO, owner) { panic(ErrTokenAlreadyExists) } // Check if person hash is already linked to another token if p.personHashExists(ic.DAO, personHash) { panic(ErrPersonHashExists) } // Get next token ID tokenID := p.getAndIncrementTokenCounter(ic.DAO) // Create token token := &state.PersonToken{ TokenID: tokenID, Owner: owner, PersonHash: personHash, IsEntity: isEntity, CreatedAt: ic.Block.Index, UpdatedAt: ic.Block.Index, Status: state.TokenStatusActive, StatusReason: "", RecoveryHash: recoveryHash, } // Store token if err := p.putToken(ic.DAO, token); err != nil { panic(err) } // Generate token ID bytes for return and event tokenIDBytes := hash.Sha256(append(owner.BytesBE(), personHash...)).BytesBE() // Emit event err = ic.AddNotification(p.Hash, PersonTokenCreatedEvent, 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 (p *PersonToken) getToken(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) token, err := p.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 (p *PersonToken) getTokenByID(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() token, err := p.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 (p *PersonToken) exists(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) return stackitem.NewBool(p.tokenExistsForOwner(ic.DAO, owner)) } // totalSupply returns the total number of tokens. func (p *PersonToken) totalSupply(ic *interop.Context, _ []stackitem.Item) stackitem.Item { cache := ic.DAO.GetROCache(p.ID).(*PersonTokenCache) return stackitem.NewBigInteger(big.NewInt(int64(cache.tokenCount))) } // suspend temporarily suspends a token (committee only). func (p *PersonToken) suspend(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) reason := toString(args[1]) // Check committee if !p.NEO.CheckCommittee(ic) { panic(ErrNotCommittee) } // Get token token, err := p.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 := p.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(p.Hash, PersonTokenSuspendedEvent, 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 (p *PersonToken) reinstate(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) // Check committee if !p.NEO.CheckCommittee(ic) { panic(ErrNotCommittee) } // Get token token, err := p.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 := p.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(p.Hash, PersonTokenReinstatedEvent, 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 (p *PersonToken) revoke(ic *interop.Context, args []stackitem.Item) stackitem.Item { owner := toUint160(args[0]) reason := toString(args[1]) // Check committee if !p.NEO.CheckCommittee(ic) { panic(ErrNotCommittee) } // Get token token, err := p.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 := p.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(p.Hash, PersonTokenRevokedEvent, 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 (p *PersonToken) GetTokenByOwner(d *dao.Simple, owner util.Uint160) (*state.PersonToken, error) { return p.getTokenByOwnerInternal(d, owner) } // GetTokenByIDPublic returns the token for the given ID (for cross-native access). func (p *PersonToken) GetTokenByIDPublic(d *dao.Simple, tokenID uint64) (*state.PersonToken, error) { return p.getTokenByIDInternal(d, tokenID) } // TokenExists returns true if a token exists for the given owner. func (p *PersonToken) TokenExists(d *dao.Simple, owner util.Uint160) bool { return p.tokenExistsForOwner(d, owner) } // Attribute storage methods func (p *PersonToken) 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(p.ID, makeAttributeKey(tokenID, attr.Key), data) return nil } func (p *PersonToken) getAttributeInternal(d *dao.Simple, tokenID uint64, key string) (*state.Attribute, error) { si := d.GetStorageItem(p.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 (p *PersonToken) 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 := p.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(ErrPersonTokenInvalidWitness) } } // 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 := p.putAttribute(ic.DAO, tokenID, attr); err != nil { panic(err) } // Emit event err = ic.AddNotification(p.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 (p *PersonToken) getAttribute(ic *interop.Context, args []stackitem.Item) stackitem.Item { tokenID := toBigInt(args[0]).Uint64() key := toString(args[1]) attr, err := p.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 (p *PersonToken) 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 := p.getTokenByIDInternal(ic.DAO, tokenID) if err != nil { panic(err) } if token == nil { panic(ErrTokenNotFound) } // Get attribute attr, err := p.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 := p.NEO.CheckCommittee(ic) if isOwner { ok, err := runtime.CheckHashedWitness(ic, token.Owner) if err != nil || !ok { panic(ErrPersonTokenInvalidWitness) } } else if !isAttestor && !isCommittee { panic(ErrPersonTokenInvalidWitness) } // Revoke attribute attr.Revoked = true attr.RevokedAt = ic.Block.Index // Store updated attribute if err := p.putAttribute(ic.DAO, tokenID, attr); err != nil { panic(err) } // Emit event err = ic.AddNotification(p.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 (p *PersonToken) 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 := p.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 (p *PersonToken) GetAttribute(d *dao.Simple, tokenID uint64, key string) (*state.Attribute, error) { return p.getAttributeInternal(d, tokenID, key) } // Challenge storage methods func (p *PersonToken) 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(p.ID, makeChallengeKey(challenge.ChallengeID), data) return nil } func (p *PersonToken) getChallengeInternal(d *dao.Simple, challengeID util.Uint256) (*state.AuthChallenge, error) { si := d.GetStorageItem(p.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 (p *PersonToken) setLastAuth(d *dao.Simple, tokenID uint64, purpose string, blockHeight uint32) { buf := make([]byte, 4) binary.BigEndian.PutUint32(buf, blockHeight) d.PutStorageItem(p.ID, makeLastAuthKey(tokenID, purpose), buf) } func (p *PersonToken) getLastAuth(d *dao.Simple, tokenID uint64, purpose string) uint32 { si := d.GetStorageItem(p.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 (p *PersonToken) 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 := p.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 := p.putChallenge(ic.DAO, challenge); err != nil { panic(err) } // Emit event err = ic.AddNotification(p.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 (p *PersonToken) 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 := p.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 (p *PersonToken) 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 := p.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 := p.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 := p.putChallenge(ic.DAO, challenge); err != nil { panic(err) } // Record last auth time for this token and purpose p.setLastAuth(ic.DAO, challenge.TokenID, challenge.Purpose, ic.Block.Index) // Emit success event err = ic.AddNotification(p.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 (p *PersonToken) 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 := p.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 (p *PersonToken) GetLastAuth(d *dao.Simple, tokenID uint64, purpose string) uint32 { return p.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 (p *PersonToken) 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(p.ID, makeRecoveryKey(req.RequestID), data) return nil } func (p *PersonToken) getRecoveryRequestInternal(d *dao.Simple, requestID util.Uint256) (*state.RecoveryRequest, error) { si := d.GetStorageItem(p.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 (p *PersonToken) setActiveRecovery(d *dao.Simple, tokenID uint64, requestID util.Uint256) { d.PutStorageItem(p.ID, makeActiveRecoveryKey(tokenID), requestID.BytesBE()) } func (p *PersonToken) getActiveRecovery(d *dao.Simple, tokenID uint64) *util.Uint256 { si := d.GetStorageItem(p.ID, makeActiveRecoveryKey(tokenID)) if si == nil { return nil } requestID, err := util.Uint256DecodeBytesBE(si) if err != nil { return nil } return &requestID } func (p *PersonToken) clearActiveRecovery(d *dao.Simple, tokenID uint64) { d.DeleteStorageItem(p.ID, makeActiveRecoveryKey(tokenID)) } // initiateRecovery starts a key recovery process for a token. // Anyone can initiate recovery for any token. func (p *PersonToken) 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 := p.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 p.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 := p.putRecoveryRequest(ic.DAO, req); err != nil { panic(err) } // Mark active recovery for token p.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 := p.putToken(ic.DAO, token); err != nil { panic(err) } // Emit event err = ic.AddNotification(p.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 (p *PersonToken) 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 !p.NEO.CheckCommittee(ic) { panic(ErrNotCommittee) } // Get recovery request req, err := p.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 := p.putRecoveryRequest(ic.DAO, req); err != nil { panic(err) } // Emit event err = ic.AddNotification(p.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 (p *PersonToken) 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 := p.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 := p.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(p.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 := p.putToken(ic.DAO, token); err != nil { panic(err) } // Update request status req.Status = state.RecoveryStatusExecuted if err := p.putRecoveryRequest(ic.DAO, req); err != nil { panic(err) } // Clear active recovery p.clearActiveRecovery(ic.DAO, req.TokenID) // Emit event err = ic.AddNotification(p.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 (p *PersonToken) 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 := p.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 := p.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 := p.NEO.CheckCommittee(ic) if isOwner { ok, err := runtime.CheckHashedWitness(ic, token.Owner) if err != nil || !ok { panic(ErrPersonTokenInvalidWitness) } } else if !isRequester && !isCommittee { panic(ErrPersonTokenInvalidWitness) } // Update request status req.Status = state.RecoveryStatusDenied if err := p.putRecoveryRequest(ic.DAO, req); err != nil { panic(err) } // Restore token status token.Status = state.TokenStatusActive token.StatusReason = "" token.UpdatedAt = ic.Block.Index if err := p.putToken(ic.DAO, token); err != nil { panic(err) } // Clear active recovery p.clearActiveRecovery(ic.DAO, req.TokenID) // Emit event err = ic.AddNotification(p.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 (p *PersonToken) 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 := p.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 (p *PersonToken) GetRecoveryRequest(d *dao.Simple, requestID util.Uint256) (*state.RecoveryRequest, error) { return p.getRecoveryRequestInternal(d, requestID) } // GetActiveRecoveryForToken returns the active recovery request ID for a token (for cross-native access). func (p *PersonToken) GetActiveRecoveryForToken(d *dao.Simple, tokenID uint64) *util.Uint256 { return p.getActiveRecovery(d, tokenID) } // Cross-contract integration methods // validateCaller validates that the calling contract's owner has a valid PersonToken. // Returns the tokenID and core roles for the caller. func (p *PersonToken) validateCaller(ic *interop.Context, args []stackitem.Item) stackitem.Item { caller := ic.VM.GetCallingScriptHash() // Get token by caller address token, err := p.getTokenByOwnerInternal(ic.DAO, caller) if err != nil { panic(err) } if token == nil { panic(ErrCallerHasNoToken) } // 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 := p.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 (p *PersonToken) getCoreRoles(ic *interop.Context, token *state.PersonToken) 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 p.hasVerifiedAttributes(ic.DAO, token.TokenID) { roles |= 1 << uint64(CoreRoleVerified) } // Check if user is a committee member if p.NEO.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 (p *PersonToken) 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(p.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 (p *PersonToken) 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 := p.getTokenByOwnerInternal(ic.DAO, caller) if err != nil { panic(err) } if token == nil { panic(ErrCallerHasNoToken) } // 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 := p.getCoreRoles(ic, token) if roles&(1< CoreRoleRecovery { panic(ErrPersonTokenInvalidRole) } caller := ic.VM.GetCallingScriptHash() // Get token by caller address token, err := p.getTokenByOwnerInternal(ic.DAO, caller) if err != nil { panic(err) } if token == nil { panic(ErrCallerHasNoToken) } // 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 := p.getCoreRoles(ic, token) if roles&(1<