From de34f66286ca7c8c87cac0e35d7c96a98e8c207e Mon Sep 17 00:00:00 2001 From: Tutus Development Date: Sat, 20 Dec 2025 01:35:26 +0000 Subject: [PATCH] Add RoleRegistry native contract for hierarchical RBAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements RoleRegistry as a native contract for role-based access control that integrates with PersonToken for democratic governance. Key features: - Built-in roles: COMMITTEE, REGISTRAR, ATTESTOR, OPERATOR - Hierarchical roles with parent inheritance - Permission system (resource/action/scope tuples) - CheckCommittee() method for admin authorization - TutusCommittee config for initial committee members PersonToken integration: - Added RoleRegistry field for cross-contract calls - checkCommittee helper delegates to RoleRegistry with NEO fallback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pkg/config/protocol_config.go | 5 + pkg/core/native/contract.go | 40 + .../native/native_test/management_test.go | 3 +- .../native/native_test/role_registry_test.go | 296 +++++ pkg/core/native/nativeids/ids.go | 2 + pkg/core/native/nativenames/names.go | 9 +- pkg/core/native/person_token.go | 29 +- pkg/core/native/role_registry.go | 1061 +++++++++++++++++ pkg/core/state/role_registry.go | 327 +++++ 9 files changed, 1759 insertions(+), 13 deletions(-) create mode 100644 pkg/core/native/native_test/role_registry_test.go create mode 100644 pkg/core/native/role_registry.go create mode 100644 pkg/core/state/role_registry.go diff --git a/pkg/config/protocol_config.go b/pkg/config/protocol_config.go index ad3fae7..4872651 100644 --- a/pkg/config/protocol_config.go +++ b/pkg/config/protocol_config.go @@ -56,6 +56,11 @@ type ( SeedList []string `yaml:"SeedList"` StandbyCommittee []string `yaml:"StandbyCommittee"` + // TutusCommittee is the list of initial Tutus committee member addresses. + // These are granted the COMMITTEE role in RoleRegistry at initialization. + // Unlike StandbyCommittee (for consensus), TutusCommittee represents + // designated officials for democratic governance (1 person = 1 vote). + TutusCommittee []string `yaml:"TutusCommittee"` // StateRootInHeader enables storing state root in block header. StateRootInHeader bool `yaml:"StateRootInHeader"` // StateSyncInterval is the number of blocks between state heights available for MPT state data synchronization. diff --git a/pkg/core/native/contract.go b/pkg/core/native/contract.go index b36c4a0..d461ff5 100644 --- a/pkg/core/native/contract.go +++ b/pkg/core/native/contract.go @@ -120,6 +120,20 @@ type ( TokenExists(d *dao.Simple, owner util.Uint160) bool GetAttribute(d *dao.Simple, tokenID uint64, key string) (*state.Attribute, error) } + + // IRoleRegistry is an interface required from native RoleRegistry contract + // for interaction with Blockchain and other native contracts. + // RoleRegistry provides democratic governance for Tutus, replacing NEO.CheckCommittee(). + IRoleRegistry interface { + interop.Contract + // CheckCommittee returns true if caller has COMMITTEE role. + // This replaces NEO.CheckCommittee() for Tutus democratic governance. + CheckCommittee(ic *interop.Context) bool + // HasRoleInternal checks if address has role (includes hierarchy). + HasRoleInternal(d *dao.Simple, address util.Uint160, roleID uint64, blockHeight uint32) bool + // HasPermissionInternal checks if address has permission via roles. + HasPermissionInternal(d *dao.Simple, address util.Uint160, resource, action string, scope state.Scope, blockHeight uint32) bool + } ) // Contracts is a convenient wrapper around an arbitrary set of native contracts @@ -241,6 +255,12 @@ func (cs *Contracts) PersonToken() IPersonToken { return cs.ByName(nativenames.PersonToken).(IPersonToken) } +// RoleRegistry returns native IRoleRegistry contract implementation. It panics if +// there's no contract with proper name in cs. +func (cs *Contracts) RoleRegistry() IRoleRegistry { + return cs.ByName(nativenames.RoleRegistry).(IRoleRegistry) +} + // NewDefaultContracts returns a new set of default native contracts. func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { mgmt := NewManagement() @@ -280,6 +300,25 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { personToken := newPersonToken() personToken.NEO = neo + // Parse TutusCommittee addresses from config + var tutusCommittee []util.Uint160 + for _, addrStr := range cfg.TutusCommittee { + addr, err := util.Uint160DecodeStringLE(addrStr) + if err != nil { + // Try parsing as hex (BE format) + addr, err = util.Uint160DecodeStringBE(addrStr) + if err != nil { + continue // Skip invalid addresses + } + } + tutusCommittee = append(tutusCommittee, addr) + } + roleRegistry := newRoleRegistry(tutusCommittee) + roleRegistry.NEO = neo + + // Set RoleRegistry on PersonToken for cross-contract integration + personToken.RoleRegistry = roleRegistry + return []interop.Contract{ mgmt, s, @@ -293,5 +332,6 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { notary, treasury, personToken, + roleRegistry, } } diff --git a/pkg/core/native/native_test/management_test.go b/pkg/core/native/native_test/management_test.go index 386b38a..d6afe55 100644 --- a/pkg/core/native/native_test/management_test.go +++ b/pkg/core/native/native_test/management_test.go @@ -51,7 +51,8 @@ var ( nativenames.Policy: `{"id":-7,"hash":"0xcc5e4edd9f5f8dba8bb65734541df7a1c081c67b","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":1094259016},"manifest":{"name":"PolicyContract","abi":{"methods":[{"name":"blockAccount","offset":0,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Boolean","safe":false},{"name":"getAttributeFee","offset":7,"parameters":[{"name":"attributeType","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"getExecFeeFactor","offset":14,"parameters":[],"returntype":"Integer","safe":true},{"name":"getFeePerByte","offset":21,"parameters":[],"returntype":"Integer","safe":true},{"name":"getStoragePrice","offset":28,"parameters":[],"returntype":"Integer","safe":true},{"name":"isBlocked","offset":35,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Boolean","safe":true},{"name":"setAttributeFee","offset":42,"parameters":[{"name":"attributeType","type":"Integer"},{"name":"value","type":"Integer"}],"returntype":"Void","safe":false},{"name":"setExecFeeFactor","offset":49,"parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","safe":false},{"name":"setFeePerByte","offset":56,"parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","safe":false},{"name":"setStoragePrice","offset":63,"parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","safe":false},{"name":"unblockAccount","offset":70,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Boolean","safe":false}],"events":[]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, nativenames.Designation: `{"id":-8,"hash":"0x49cf4e5378ffcd4dec034fd98a174c5491e395e2","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0A=","checksum":983638438},"manifest":{"name":"RoleManagement","abi":{"methods":[{"name":"designateAsRole","offset":0,"parameters":[{"name":"role","type":"Integer"},{"name":"nodes","type":"Array"}],"returntype":"Void","safe":false},{"name":"getDesignatedByRole","offset":7,"parameters":[{"name":"role","type":"Integer"},{"name":"index","type":"Integer"}],"returntype":"Array","safe":true}],"events":[{"name":"Designation","parameters":[{"name":"Role","type":"Integer"},{"name":"BlockIndex","type":"Integer"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, nativenames.Oracle: `{"id":-9,"hash":"0xfe924b7cfe89ddd271abaf7210a80a7e11178758","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":2663858513},"manifest":{"name":"OracleContract","abi":{"methods":[{"name":"finish","offset":0,"parameters":[],"returntype":"Void","safe":false},{"name":"getPrice","offset":7,"parameters":[],"returntype":"Integer","safe":true},{"name":"request","offset":14,"parameters":[{"name":"url","type":"String"},{"name":"filter","type":"String"},{"name":"callback","type":"String"},{"name":"userData","type":"Any"},{"name":"gasForResponse","type":"Integer"}],"returntype":"Void","safe":false},{"name":"setPrice","offset":21,"parameters":[{"name":"price","type":"Integer"}],"returntype":"Void","safe":false},{"name":"verify","offset":28,"parameters":[],"returntype":"Boolean","safe":true}],"events":[{"name":"OracleRequest","parameters":[{"name":"Id","type":"Integer"},{"name":"RequestContract","type":"Hash160"},{"name":"Url","type":"String"},{"name":"Filter","type":"String"}]},{"name":"OracleResponse","parameters":[{"name":"Id","type":"Integer"},{"name":"OriginalTx","type":"Hash256"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, - nativenames.PersonToken: `{"id":-12,"hash":"0x21a5728a3a261c77c7df30d47aa4dbdef134af04","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQA==","checksum":2426471238},"manifest":{"name":"PersonToken","abi":{"methods":[{"name":"approveRecovery","offset":0,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"cancelRecovery","offset":7,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"createChallenge","offset":14,"parameters":[{"name":"owner","type":"Hash160"},{"name":"purpose","type":"String"}],"returntype":"Array","safe":false},{"name":"executeRecovery","offset":21,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"exists","offset":28,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Boolean","safe":true},{"name":"fulfillChallenge","offset":35,"parameters":[{"name":"challengeId","type":"ByteArray"},{"name":"signature","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"getAttribute","offset":42,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"}],"returntype":"Array","safe":true},{"name":"getChallenge","offset":49,"parameters":[{"name":"challengeId","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"getRecoveryRequest","offset":56,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"getToken","offset":63,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"getTokenByID","offset":70,"parameters":[{"name":"tokenId","type":"Integer"}],"returntype":"Array","safe":true},{"name":"initiateRecovery","offset":77,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"newOwner","type":"Hash160"},{"name":"evidence","type":"ByteArray"}],"returntype":"ByteArray","safe":false},{"name":"register","offset":84,"parameters":[{"name":"owner","type":"Hash160"},{"name":"personHash","type":"ByteArray"},{"name":"isEntity","type":"Boolean"},{"name":"recoveryHash","type":"ByteArray"}],"returntype":"ByteArray","safe":false},{"name":"reinstate","offset":91,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Boolean","safe":false},{"name":"requireCoreRole","offset":98,"parameters":[{"name":"coreRole","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"requirePermission","offset":105,"parameters":[{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"String"}],"returntype":"Integer","safe":true},{"name":"requireRole","offset":112,"parameters":[{"name":"roleId","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"revoke","offset":119,"parameters":[{"name":"owner","type":"Hash160"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"revokeAttribute","offset":126,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"setAttribute","offset":133,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"valueHash","type":"ByteArray"},{"name":"valueEnc","type":"ByteArray"},{"name":"expiresAt","type":"Integer"},{"name":"disclosureLevel","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"suspend","offset":140,"parameters":[{"name":"owner","type":"Hash160"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"totalSupply","offset":147,"parameters":[],"returntype":"Integer","safe":true},{"name":"validateCaller","offset":154,"parameters":[],"returntype":"Array","safe":true},{"name":"verifyAttribute","offset":161,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"expectedHash","type":"ByteArray"}],"returntype":"Boolean","safe":true},{"name":"verifyAuth","offset":168,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"maxAge","type":"Integer"}],"returntype":"Boolean","safe":true}],"events":[{"name":"PersonTokenCreated","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"owner","type":"Hash160"},{"name":"createdAt","type":"Integer"}]},{"name":"PersonTokenSuspended","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reason","type":"String"},{"name":"suspendedBy","type":"Hash160"}]},{"name":"PersonTokenReinstated","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reinstatedBy","type":"Hash160"}]},{"name":"PersonTokenRevoked","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reason","type":"String"},{"name":"revokedBy","type":"Hash160"}]},{"name":"AttributeSet","parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"attestor","type":"Hash160"},{"name":"expiresAt","type":"Integer"}]},{"name":"AttributeRevoked","parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"revokedBy","type":"Hash160"},{"name":"reason","type":"String"}]},{"name":"AuthChallengeCreated","parameters":[{"name":"challengeId","type":"ByteArray"},{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"expiresAt","type":"Integer"}]},{"name":"AuthenticationSuccess","parameters":[{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"timestamp","type":"Integer"}]},{"name":"RecoveryInitiated","parameters":[{"name":"tokenId","type":"Integer"},{"name":"requestId","type":"ByteArray"},{"name":"delayUntil","type":"Integer"}]},{"name":"RecoveryApproval","parameters":[{"name":"requestId","type":"ByteArray"},{"name":"approver","type":"Hash160"},{"name":"approvalCount","type":"Integer"},{"name":"required","type":"Integer"}]},{"name":"RecoveryExecuted","parameters":[{"name":"tokenId","type":"Integer"},{"name":"oldOwner","type":"Hash160"},{"name":"newOwner","type":"Hash160"}]},{"name":"RecoveryCancelled","parameters":[{"name":"requestId","type":"ByteArray"},{"name":"cancelledBy","type":"Hash160"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, + nativenames.PersonToken: `{"id":-12,"hash":"0x21a5728a3a261c77c7df30d47aa4dbdef134af04","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQA==","checksum":2426471238},"manifest":{"name":"PersonToken","abi":{"methods":[{"name":"approveRecovery","offset":0,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"cancelRecovery","offset":7,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"createChallenge","offset":14,"parameters":[{"name":"owner","type":"Hash160"},{"name":"purpose","type":"String"}],"returntype":"Array","safe":false},{"name":"executeRecovery","offset":21,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"exists","offset":28,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Boolean","safe":true},{"name":"fulfillChallenge","offset":35,"parameters":[{"name":"challengeId","type":"ByteArray"},{"name":"signature","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"getAttribute","offset":42,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"}],"returntype":"Array","safe":true},{"name":"getChallenge","offset":49,"parameters":[{"name":"challengeId","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"getRecoveryRequest","offset":56,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"getToken","offset":63,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"getTokenByID","offset":70,"parameters":[{"name":"tokenId","type":"Integer"}],"returntype":"Array","safe":true},{"name":"initiateRecovery","offset":77,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"newOwner","type":"Hash160"},{"name":"evidence","type":"ByteArray"}],"returntype":"ByteArray","safe":false},{"name":"register","offset":84,"parameters":[{"name":"owner","type":"Hash160"},{"name":"personHash","type":"ByteArray"},{"name":"isEntity","type":"Boolean"},{"name":"recoveryHash","type":"ByteArray"}],"returntype":"ByteArray","safe":false},{"name":"reinstate","offset":91,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Boolean","safe":false},{"name":"requireCoreRole","offset":98,"parameters":[{"name":"coreRole","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"requirePermission","offset":105,"parameters":[{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"String"}],"returntype":"Integer","safe":true},{"name":"requireRole","offset":112,"parameters":[{"name":"roleId","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"revoke","offset":119,"parameters":[{"name":"owner","type":"Hash160"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"revokeAttribute","offset":126,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"setAttribute","offset":133,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"valueHash","type":"ByteArray"},{"name":"valueEnc","type":"ByteArray"},{"name":"expiresAt","type":"Integer"},{"name":"disclosureLevel","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"suspend","offset":140,"parameters":[{"name":"owner","type":"Hash160"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"totalSupply","offset":147,"parameters":[],"returntype":"Integer","safe":true},{"name":"validateCaller","offset":154,"parameters":[],"returntype":"Array","safe":true},{"name":"verifyAttribute","offset":161,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"expectedHash","type":"ByteArray"}],"returntype":"Boolean","safe":true},{"name":"verifyAuth","offset":168,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"maxAge","type":"Integer"}],"returntype":"Boolean","safe":true}],"events":[{"name":"PersonTokenCreated","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"owner","type":"Hash160"},{"name":"createdAt","type":"Integer"}]},{"name":"PersonTokenSuspended","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reason","type":"String"},{"name":"suspendedBy","type":"Hash160"}]},{"name":"PersonTokenReinstated","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reinstatedBy","type":"Hash160"}]},{"name":"PersonTokenRevoked","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reason","type":"String"},{"name":"revokedBy","type":"Hash160"}]},{"name":"AttributeSet","parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"attestor","type":"Hash160"},{"name":"expiresAt","type":"Integer"}]},{"name":"AttributeRevoked","parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"revokedBy","type":"Hash160"},{"name":"reason","type":"String"}]},{"name":"AuthChallengeCreated","parameters":[{"name":"challengeId","type":"ByteArray"},{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"expiresAt","type":"Integer"}]},{"name":"AuthenticationSuccess","parameters":[{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"timestamp","type":"Integer"}]},{"name":"RecoveryInitiated","parameters":[{"name":"tokenId","type":"Integer"},{"name":"requestId","type":"ByteArray"},{"name":"delayUntil","type":"Integer"}]},{"name":"RecoveryApproval","parameters":[{"name":"requestId","type":"ByteArray"},{"name":"approver","type":"Hash160"},{"name":"approvalCount","type":"Integer"},{"name":"required","type":"Integer"}]},{"name":"RecoveryExecuted","parameters":[{"name":"tokenId","type":"Integer"},{"name":"oldOwner","type":"Hash160"},{"name":"newOwner","type":"Hash160"}]},{"name":"RecoveryCancelled","parameters":[{"name":"requestId","type":"ByteArray"},{"name":"cancelledBy","type":"Hash160"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, + nativenames.RoleRegistry: `{"id":-13,"hash":"0x52200161c6f0b581b590d41af8ccc577dc7477a9","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQA==","checksum":174904780},"manifest":{"name":"RoleRegistry","abi":{"methods":[{"name":"assignPermission","offset":0,"parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"createRole","offset":7,"parameters":[{"name":"name","type":"String"},{"name":"description","type":"String"},{"name":"parentID","type":"Integer"}],"returntype":"Integer","safe":false},{"name":"deleteRole","offset":14,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"getPermissions","offset":21,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getRole","offset":28,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getRoleByName","offset":35,"parameters":[{"name":"name","type":"String"}],"returntype":"Array","safe":true},{"name":"getRolesForAddress","offset":42,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"grantRole","offset":49,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"expiresAt","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"hasPermission","offset":56,"parameters":[{"name":"address","type":"Hash160"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"hasRole","offset":63,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"removePermission","offset":70,"parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"}],"returntype":"Boolean","safe":false},{"name":"revokeRole","offset":77,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"totalRoles","offset":84,"parameters":[],"returntype":"Integer","safe":true}],"events":[{"name":"RoleCreated","parameters":[{"name":"roleID","type":"Integer"},{"name":"name","type":"String"},{"name":"parentID","type":"Integer"},{"name":"createdBy","type":"Hash160"}]},{"name":"RoleDeleted","parameters":[{"name":"roleID","type":"Integer"},{"name":"deletedBy","type":"Hash160"}]},{"name":"RoleGranted","parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"expiresAt","type":"Integer"},{"name":"grantedBy","type":"Hash160"}]},{"name":"RoleRevoked","parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"revokedBy","type":"Hash160"}]},{"name":"PermissionAssigned","parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}]},{"name":"PermissionRemoved","parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, } // cockatriceCSS holds serialized native contract states built for genesis block (with UpdateCounter 0) // under assumption that hardforks from Aspidochelone to Cockatrice (included) are enabled. diff --git a/pkg/core/native/native_test/role_registry_test.go b/pkg/core/native/native_test/role_registry_test.go new file mode 100644 index 0000000..de243aa --- /dev/null +++ b/pkg/core/native/native_test/role_registry_test.go @@ -0,0 +1,296 @@ +package native_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tutus-one/tutus-chain/pkg/core/native/nativenames" + "github.com/tutus-one/tutus-chain/pkg/neotest" + "github.com/tutus-one/tutus-chain/pkg/vm/stackitem" +) + +func newRoleRegistryClient(t *testing.T) *neotest.ContractInvoker { + return newNativeClient(t, nativenames.RoleRegistry) +} + +// TestRoleRegistry_BuiltinRoles tests that built-in roles are created at initialization. +func TestRoleRegistry_BuiltinRoles(t *testing.T) { + c := newRoleRegistryClient(t) + + // Check totalRoles returns at least 4 (the built-in roles) + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + count, err := stack[0].TryInteger() + require.NoError(t, err) + require.GreaterOrEqual(t, count.Int64(), int64(4)) + }, "totalRoles") + + // Check COMMITTEE role exists (ID=1) + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + // Should be an array (role struct) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array for role") + require.Equal(t, 7, len(arr)) // Role has 7 fields + + // Check role ID is 1 + id, err := arr[0].TryInteger() + require.NoError(t, err) + require.Equal(t, int64(1), id.Int64()) + + // Check name is COMMITTEE + name, err := arr[1].TryBytes() + require.NoError(t, err) + require.Equal(t, "COMMITTEE", string(name)) + + // Check role is active + active, err := arr[6].TryBool() + require.NoError(t, err) + require.True(t, active) + }, "getRole", 1) + + // Check REGISTRAR role exists (ID=2) + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array for role") + name, err := arr[1].TryBytes() + require.NoError(t, err) + require.Equal(t, "REGISTRAR", string(name)) + }, "getRole", 2) + + // Check ATTESTOR role exists (ID=3) + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array for role") + name, err := arr[1].TryBytes() + require.NoError(t, err) + require.Equal(t, "ATTESTOR", string(name)) + }, "getRole", 3) + + // Check OPERATOR role exists (ID=4) + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array for role") + name, err := arr[1].TryBytes() + require.NoError(t, err) + require.Equal(t, "OPERATOR", string(name)) + }, "getRole", 4) +} + +// TestRoleRegistry_GetRoleByName tests looking up roles by name. +func TestRoleRegistry_GetRoleByName(t *testing.T) { + c := newRoleRegistryClient(t) + + // Look up COMMITTEE by name + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array for role") + id, err := arr[0].TryInteger() + require.NoError(t, err) + require.Equal(t, int64(1), id.Int64()) + }, "getRoleByName", "COMMITTEE") + + // Look up non-existent role by name + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + require.Nil(t, stack[0].Value()) + }, "getRoleByName", "NONEXISTENT") +} + +// TestRoleRegistry_HasRole tests checking if an address has a role. +func TestRoleRegistry_HasRole(t *testing.T) { + c := newRoleRegistryClient(t) + e := c.Executor + + acc := e.NewAccount(t) + + // Account should not have COMMITTEE role initially + c.Invoke(t, false, "hasRole", acc.ScriptHash(), 1) + + // Grant COMMITTEE role (committee only) + committeeInvoker := c.WithSigners(c.Committee) + committeeInvoker.Invoke(t, true, "grantRole", acc.ScriptHash(), 1, 0) + + // Now account should have COMMITTEE role + c.Invoke(t, true, "hasRole", acc.ScriptHash(), 1) +} + +// TestRoleRegistry_GrantRevokeRole tests granting and revoking roles. +func TestRoleRegistry_GrantRevokeRole(t *testing.T) { + c := newRoleRegistryClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + // Non-committee cannot grant roles + userInvoker := c.WithSigners(acc) + userInvoker.InvokeFail(t, "caller is not a committee member", "grantRole", acc.ScriptHash(), 2, 0) + + // Committee can grant role + committeeInvoker.Invoke(t, true, "grantRole", acc.ScriptHash(), 2, 0) + + // Check role is granted + c.Invoke(t, true, "hasRole", acc.ScriptHash(), 2) + + // Get roles for address + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array") + require.GreaterOrEqual(t, len(arr), 1) + }, "getRolesForAddress", acc.ScriptHash()) + + // Non-committee cannot revoke roles + userInvoker.InvokeFail(t, "caller is not a committee member", "revokeRole", acc.ScriptHash(), 2) + + // Committee can revoke role + committeeInvoker.Invoke(t, true, "revokeRole", acc.ScriptHash(), 2) + + // Check role is revoked + c.Invoke(t, false, "hasRole", acc.ScriptHash(), 2) +} + +// TestRoleRegistry_CreateRole tests creating custom roles. +func TestRoleRegistry_CreateRole(t *testing.T) { + c := newRoleRegistryClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + // Non-committee cannot create roles + userInvoker := c.WithSigners(acc) + userInvoker.InvokeFail(t, "caller is not a committee member", "createRole", "CUSTOM_ROLE", "A custom role", 0) + + // Committee can create role + committeeInvoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + roleID, err := stack[0].TryInteger() + require.NoError(t, err) + require.GreaterOrEqual(t, roleID.Int64(), int64(5)) // Custom roles start at 5 + }, "createRole", "CUSTOM_ROLE", "A custom role", 0) + + // Verify role exists + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array for role") + name, err := arr[1].TryBytes() + require.NoError(t, err) + require.Equal(t, "CUSTOM_ROLE", string(name)) + }, "getRoleByName", "CUSTOM_ROLE") + + // Cannot create duplicate role name + committeeInvoker.InvokeFail(t, "role name already exists", "createRole", "CUSTOM_ROLE", "Duplicate", 0) +} + +// TestRoleRegistry_DeleteRole tests deleting (deactivating) roles. +func TestRoleRegistry_DeleteRole(t *testing.T) { + c := newRoleRegistryClient(t) + committeeInvoker := c.WithSigners(c.Committee) + + // Create a custom role first + var customRoleID int64 + committeeInvoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + roleID, _ := stack[0].TryInteger() + customRoleID = roleID.Int64() + }, "createRole", "DELETE_TEST", "Role to delete", 0) + + // Cannot delete built-in roles + committeeInvoker.InvokeFail(t, "cannot modify built-in role", "deleteRole", 1) + committeeInvoker.InvokeFail(t, "cannot modify built-in role", "deleteRole", 2) + committeeInvoker.InvokeFail(t, "cannot modify built-in role", "deleteRole", 3) + committeeInvoker.InvokeFail(t, "cannot modify built-in role", "deleteRole", 4) + + // Can delete custom role + committeeInvoker.Invoke(t, true, "deleteRole", customRoleID) + + // Verify role is deactivated (still exists but not active) + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array for role") + active, err := arr[6].TryBool() + require.NoError(t, err) + require.False(t, active) + }, "getRole", customRoleID) +} + +// TestRoleRegistry_Permissions tests assigning and checking permissions. +func TestRoleRegistry_Permissions(t *testing.T) { + c := newRoleRegistryClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + // Create a custom role + var customRoleID int64 + committeeInvoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + roleID, _ := stack[0].TryInteger() + customRoleID = roleID.Int64() + }, "createRole", "PERM_TEST", "Permission test role", 0) + + // Assign permission to role + committeeInvoker.Invoke(t, true, "assignPermission", customRoleID, "documents", "read", 0) + + // Grant role to account + committeeInvoker.Invoke(t, true, "grantRole", acc.ScriptHash(), customRoleID, 0) + + // Check account has permission + c.Invoke(t, true, "hasPermission", acc.ScriptHash(), "documents", "read", 0) + + // Check account does NOT have other permission + c.Invoke(t, false, "hasPermission", acc.ScriptHash(), "documents", "write", 0) + + // Get permissions for role + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array") + require.Equal(t, 1, len(arr)) + }, "getPermissions", customRoleID) + + // Remove permission + committeeInvoker.Invoke(t, true, "removePermission", customRoleID, "documents", "read") + + // Check permission is gone + c.Invoke(t, false, "hasPermission", acc.ScriptHash(), "documents", "read", 0) +} + +// TestRoleRegistry_RoleHierarchy tests role hierarchy (parent roles). +func TestRoleRegistry_RoleHierarchy(t *testing.T) { + c := newRoleRegistryClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + // Create parent role + var parentRoleID int64 + committeeInvoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + roleID, _ := stack[0].TryInteger() + parentRoleID = roleID.Int64() + }, "createRole", "PARENT_ROLE", "Parent role", 0) + + // Create child role with parent + var childRoleID int64 + committeeInvoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + roleID, _ := stack[0].TryInteger() + childRoleID = roleID.Int64() + }, "createRole", "CHILD_ROLE", "Child role", parentRoleID) + + // Grant child role to account + committeeInvoker.Invoke(t, true, "grantRole", acc.ScriptHash(), childRoleID, 0) + + // Check account has child role + c.Invoke(t, true, "hasRole", acc.ScriptHash(), childRoleID) + + // Check account ALSO has parent role through hierarchy + c.Invoke(t, true, "hasRole", acc.ScriptHash(), parentRoleID) +} diff --git a/pkg/core/native/nativeids/ids.go b/pkg/core/native/nativeids/ids.go index 1b0474c..3866c9f 100644 --- a/pkg/core/native/nativeids/ids.go +++ b/pkg/core/native/nativeids/ids.go @@ -31,4 +31,6 @@ const ( Treasury int32 = -11 // PersonToken is an ID of native PersonToken contract. PersonToken int32 = -12 + // RoleRegistry is an ID of native RoleRegistry contract. + RoleRegistry int32 = -13 ) diff --git a/pkg/core/native/nativenames/names.go b/pkg/core/native/nativenames/names.go index 99de9f2..87350a5 100644 --- a/pkg/core/native/nativenames/names.go +++ b/pkg/core/native/nativenames/names.go @@ -12,8 +12,9 @@ const ( Notary = "Notary" CryptoLib = "CryptoLib" StdLib = "StdLib" - Treasury = "Treasury" - PersonToken = "PersonToken" + Treasury = "Treasury" + PersonToken = "PersonToken" + RoleRegistry = "RoleRegistry" ) // All contains the list of all native contract names ordered by the contract ID. @@ -30,6 +31,7 @@ var All = []string{ Notary, Treasury, PersonToken, + RoleRegistry, } // IsValid checks if the name is a valid native contract's name. @@ -45,5 +47,6 @@ func IsValid(name string) bool { name == CryptoLib || name == StdLib || name == Treasury || - name == PersonToken + name == PersonToken || + name == RoleRegistry } diff --git a/pkg/core/native/person_token.go b/pkg/core/native/person_token.go index d7a2084..b01eb89 100644 --- a/pkg/core/native/person_token.go +++ b/pkg/core/native/person_token.go @@ -25,7 +25,8 @@ import ( // PersonToken represents a soul-bound identity native contract. type PersonToken struct { interop.ContractMD - NEO INEO + NEO INEO + RoleRegistry IRoleRegistry } // PersonTokenCache represents the cached state for PersonToken contract. @@ -129,6 +130,16 @@ func (c *PersonTokenCache) Copy() dao.NativeContractCache { } } +// checkCommittee checks if the caller has committee authority. +// Uses RoleRegistry if available, falls back to NEO.CheckCommittee(). +func (p *PersonToken) checkCommittee(ic *interop.Context) bool { + if p.RoleRegistry != nil { + return p.RoleRegistry.CheckCommittee(ic) + } + // Fallback to NEO for backwards compatibility + return p.NEO.CheckCommittee(ic) +} + // newPersonToken creates a new PersonToken native contract. func newPersonToken() *PersonToken { p := &PersonToken{ @@ -709,7 +720,7 @@ func (p *PersonToken) suspend(ic *interop.Context, args []stackitem.Item) stacki reason := toString(args[1]) // Check committee - if !p.NEO.CheckCommittee(ic) { + if !p.checkCommittee(ic) { panic(ErrNotCommittee) } @@ -762,7 +773,7 @@ func (p *PersonToken) reinstate(ic *interop.Context, args []stackitem.Item) stac owner := toUint160(args[0]) // Check committee - if !p.NEO.CheckCommittee(ic) { + if !p.checkCommittee(ic) { panic(ErrNotCommittee) } @@ -815,7 +826,7 @@ func (p *PersonToken) revoke(ic *interop.Context, args []stackitem.Item) stackit reason := toString(args[1]) // Check committee - if !p.NEO.CheckCommittee(ic) { + if !p.checkCommittee(ic) { panic(ErrNotCommittee) } @@ -1044,7 +1055,7 @@ func (p *PersonToken) revokeAttribute(ic *interop.Context, args []stackitem.Item caller := ic.VM.GetCallingScriptHash() isOwner := caller.Equals(token.Owner) isAttestor := caller.Equals(attr.Attestor) - isCommittee := p.NEO.CheckCommittee(ic) + isCommittee := p.checkCommittee(ic) if isOwner { ok, err := runtime.CheckHashedWitness(ic, token.Owner) @@ -1532,7 +1543,7 @@ func (p *PersonToken) approveRecovery(ic *interop.Context, args []stackitem.Item } // Check committee - if !p.NEO.CheckCommittee(ic) { + if !p.checkCommittee(ic) { panic(ErrNotCommittee) } @@ -1714,7 +1725,7 @@ func (p *PersonToken) cancelRecovery(ic *interop.Context, args []stackitem.Item) caller := ic.VM.GetCallingScriptHash() isOwner := caller.Equals(token.Owner) isRequester := caller.Equals(req.Requester) - isCommittee := p.NEO.CheckCommittee(ic) + isCommittee := p.checkCommittee(ic) if isOwner { ok, err := runtime.CheckHashedWitness(ic, token.Owner) @@ -1845,7 +1856,7 @@ func (p *PersonToken) getCoreRoles(ic *interop.Context, token *state.PersonToken } // Check if user is a committee member - if p.NEO.CheckCommittee(ic) { + if p.checkCommittee(ic) { roles |= 1 << uint64(CoreRoleCommittee) roles |= 1 << uint64(CoreRoleAttestor) // Committee members can attest roles |= 1 << uint64(CoreRoleRecovery) // Committee members participate in recovery @@ -1980,7 +1991,7 @@ func (p *PersonToken) requirePermission(ic *interop.Context, args []stackitem.It // Stub: In future, this will check against RoleRegistry RBAC // For now, committee members have all permissions - if p.NEO.CheckCommittee(ic) { + if p.checkCommittee(ic) { return stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))) } diff --git a/pkg/core/native/role_registry.go b/pkg/core/native/role_registry.go new file mode 100644 index 0000000..3fdc2a5 --- /dev/null +++ b/pkg/core/native/role_registry.go @@ -0,0 +1,1061 @@ +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/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" +) + +// RoleRegistry is a native contract for hierarchical role-based access control. +// It replaces NEO.CheckCommittee() with democratic governance based on Vita (PersonToken). +type RoleRegistry struct { + interop.ContractMD + + // NEO is used for fallback committee checks when TutusCommittee is not set. + NEO INEO + + // tutusCommittee contains initial committee member addresses from config. + tutusCommittee []util.Uint160 +} + +// RoleRegistryCache contains cached role registry data. +type RoleRegistryCache struct { + roleCount uint64 +} + +// Built-in role IDs. +const ( + // RoleCommittee is the Tutus Committee role (designated officials). + RoleCommittee uint64 = 1 + // RoleRegistrar can register new PersonTokens. + RoleRegistrar uint64 = 2 + // RoleAttestor can attest identity attributes. + RoleAttestor uint64 = 3 + // RoleOperator is a system operator (node management). + RoleOperator uint64 = 4 +) + +// Storage key prefixes for RoleRegistry. +const ( + rrPrefixRole byte = 0x01 // roleID -> Role + rrPrefixRoleName byte = 0x02 // name -> roleID + rrPrefixRoleAssignment byte = 0x03 // tokenID + roleID -> RoleAssignment + rrPrefixTokenRoles byte = 0x04 // tokenID -> []roleID + rrPrefixRoleMembers byte = 0x05 // roleID -> []tokenID + rrPrefixPermission byte = 0x06 // roleID + resource + action -> PermissionGrant + rrPrefixRolePermissions byte = 0x07 // roleID -> []permission keys + rrPrefixCounter byte = 0x10 // "counter" -> next roleID + rrPrefixConfig byte = 0x11 // "config" -> config + rrPrefixAddressAssignment byte = 0x12 // address + roleID -> AddressRoleAssignment + rrPrefixAddressRoles byte = 0x13 // address -> []roleID +) + +// Max lengths for validation. +const ( + maxRoleNameLength = 64 + maxRoleDescriptionLength = 256 + maxResourceLength = 128 + maxActionLength = 64 +) + +// Errors. +var ( + ErrRoleNotFound = errors.New("role not found") + ErrRoleNameExists = errors.New("role name already exists") + ErrRoleNameTooLong = errors.New("role name too long") + ErrRoleDescTooLong = errors.New("role description too long") + ErrRoleNotActive = errors.New("role is not active") + ErrAssignmentNotFound = errors.New("role assignment not found") + ErrAssignmentExists = errors.New("role assignment already exists") + ErrAssignmentExpired = errors.New("role assignment has expired") + ErrPermissionNotFound = errors.New("permission not found") + ErrPermissionExists = errors.New("permission already exists") + ErrResourceTooLong = errors.New("resource identifier too long") + ErrActionTooLong = errors.New("action identifier too long") + ErrInvalidScope = errors.New("invalid scope") + ErrRoleRegistryNotCommittee = errors.New("caller is not a committee member") + ErrBuiltinRole = errors.New("cannot modify built-in role") + ErrInvalidParentRole = errors.New("invalid parent role") + ErrCircularHierarchy = errors.New("circular role hierarchy detected") +) + +// Event names. +const ( + RoleCreatedEvent = "RoleCreated" + RoleDeletedEvent = "RoleDeleted" + RoleGrantedEvent = "RoleGranted" + RoleRevokedEvent = "RoleRevoked" + PermissionAssignedEvent = "PermissionAssigned" + PermissionRemovedEvent = "PermissionRemoved" +) + +var ( + _ interop.Contract = (*RoleRegistry)(nil) + _ dao.NativeContractCache = (*RoleRegistryCache)(nil) +) + +// Copy implements NativeContractCache interface. +func (c *RoleRegistryCache) Copy() dao.NativeContractCache { + return &RoleRegistryCache{roleCount: c.roleCount} +} + +// newRoleRegistry creates a new RoleRegistry contract. +func newRoleRegistry(tutusCommittee []util.Uint160) *RoleRegistry { + r := &RoleRegistry{ + ContractMD: *interop.NewContractMD(nativenames.RoleRegistry, nativeids.RoleRegistry), + tutusCommittee: tutusCommittee, + } + defer r.BuildHFSpecificMD(r.ActiveIn()) + + // Query methods + desc := NewDescriptor("totalRoles", smartcontract.IntegerType) + md := NewMethodAndPrice(r.totalRoles, 1<<15, callflag.ReadStates) + r.AddMethod(md, desc) + + desc = NewDescriptor("getRole", smartcontract.ArrayType, + manifest.NewParameter("roleID", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.getRole, 1<<15, callflag.ReadStates) + r.AddMethod(md, desc) + + desc = NewDescriptor("getRoleByName", smartcontract.ArrayType, + manifest.NewParameter("name", smartcontract.StringType)) + md = NewMethodAndPrice(r.getRoleByName, 1<<15, callflag.ReadStates) + r.AddMethod(md, desc) + + desc = NewDescriptor("hasRole", smartcontract.BoolType, + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("roleID", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.hasRole, 1<<15, callflag.ReadStates) + r.AddMethod(md, desc) + + desc = NewDescriptor("getRolesForAddress", smartcontract.ArrayType, + manifest.NewParameter("address", smartcontract.Hash160Type)) + md = NewMethodAndPrice(r.getRolesForAddress, 1<<15, callflag.ReadStates) + r.AddMethod(md, desc) + + desc = NewDescriptor("getPermissions", smartcontract.ArrayType, + manifest.NewParameter("roleID", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.getPermissions, 1<<15, callflag.ReadStates) + r.AddMethod(md, desc) + + desc = NewDescriptor("hasPermission", smartcontract.BoolType, + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("resource", smartcontract.StringType), + manifest.NewParameter("action", smartcontract.StringType), + manifest.NewParameter("scope", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.hasPermission, 1<<15, callflag.ReadStates) + r.AddMethod(md, desc) + + // Admin methods (committee-only) + desc = NewDescriptor("createRole", smartcontract.IntegerType, + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("description", smartcontract.StringType), + manifest.NewParameter("parentID", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.createRole, 1<<15, callflag.States|callflag.AllowNotify) + r.AddMethod(md, desc) + + desc = NewDescriptor("deleteRole", smartcontract.BoolType, + manifest.NewParameter("roleID", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.deleteRole, 1<<15, callflag.States|callflag.AllowNotify) + r.AddMethod(md, desc) + + desc = NewDescriptor("grantRole", smartcontract.BoolType, + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("expiresAt", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.grantRole, 1<<15, callflag.States|callflag.AllowNotify) + r.AddMethod(md, desc) + + desc = NewDescriptor("revokeRole", smartcontract.BoolType, + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("roleID", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.revokeRole, 1<<15, callflag.States|callflag.AllowNotify) + r.AddMethod(md, desc) + + desc = NewDescriptor("assignPermission", smartcontract.BoolType, + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("resource", smartcontract.StringType), + manifest.NewParameter("action", smartcontract.StringType), + manifest.NewParameter("scope", smartcontract.IntegerType)) + md = NewMethodAndPrice(r.assignPermission, 1<<15, callflag.States|callflag.AllowNotify) + r.AddMethod(md, desc) + + desc = NewDescriptor("removePermission", smartcontract.BoolType, + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("resource", smartcontract.StringType), + manifest.NewParameter("action", smartcontract.StringType)) + md = NewMethodAndPrice(r.removePermission, 1<<15, callflag.States|callflag.AllowNotify) + r.AddMethod(md, desc) + + // Events + eDesc := NewEventDescriptor(RoleCreatedEvent, + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("parentID", smartcontract.IntegerType), + manifest.NewParameter("createdBy", smartcontract.Hash160Type)) + eMD := NewEvent(eDesc) + r.AddEvent(eMD) + + eDesc = NewEventDescriptor(RoleDeletedEvent, + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("deletedBy", smartcontract.Hash160Type)) + eMD = NewEvent(eDesc) + r.AddEvent(eMD) + + eDesc = NewEventDescriptor(RoleGrantedEvent, + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("expiresAt", smartcontract.IntegerType), + manifest.NewParameter("grantedBy", smartcontract.Hash160Type)) + eMD = NewEvent(eDesc) + r.AddEvent(eMD) + + eDesc = NewEventDescriptor(RoleRevokedEvent, + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("revokedBy", smartcontract.Hash160Type)) + eMD = NewEvent(eDesc) + r.AddEvent(eMD) + + eDesc = NewEventDescriptor(PermissionAssignedEvent, + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("resource", smartcontract.StringType), + manifest.NewParameter("action", smartcontract.StringType), + manifest.NewParameter("scope", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + r.AddEvent(eMD) + + eDesc = NewEventDescriptor(PermissionRemovedEvent, + manifest.NewParameter("roleID", smartcontract.IntegerType), + manifest.NewParameter("resource", smartcontract.StringType), + manifest.NewParameter("action", smartcontract.StringType)) + eMD = NewEvent(eDesc) + r.AddEvent(eMD) + + return r +} + +// Initialize initializes RoleRegistry contract at genesis. +func (r *RoleRegistry) Initialize(ic *interop.Context, hf *config.Hardfork, newMD *interop.HFSpecificContractMD) error { + if hf != r.ActiveIn() { + return nil + } + + cache := &RoleRegistryCache{roleCount: 0} + ic.DAO.SetCache(r.ID, cache) + + // Initialize role counter starting at the first custom role ID (after built-ins) + r.putRoleCounter(ic.DAO, 5) // Built-in roles are 1-4, custom roles start at 5 + + // Create built-in roles + builtinRoles := []struct { + id uint64 + name string + description string + }{ + {RoleCommittee, "COMMITTEE", "Tutus Committee member (designated officials)"}, + {RoleRegistrar, "REGISTRAR", "Can register new PersonTokens"}, + {RoleAttestor, "ATTESTOR", "Can attest identity attributes"}, + {RoleOperator, "OPERATOR", "System operator (node management)"}, + } + + for _, br := range builtinRoles { + role := &state.Role{ + ID: br.id, + Name: br.name, + Description: br.description, + ParentID: 0, + CreatedAt: 0, + CreatedBy: util.Uint160{}, + Active: true, + } + err := r.putRole(ic.DAO, role) + if err != nil { + return fmt.Errorf("failed to create built-in role %s: %w", br.name, err) + } + r.putRoleNameIndex(ic.DAO, br.name, br.id) + cache.roleCount++ + } + + // Grant COMMITTEE role to initial committee members from config + for _, addr := range r.tutusCommittee { + assignment := &state.AddressRoleAssignment{ + Address: addr, + RoleID: RoleCommittee, + GrantedAt: 0, + GrantedBy: util.Uint160{}, + ExpiresAt: 0, // Never expires + Active: true, + } + err := r.putAddressRoleAssignment(ic.DAO, assignment) + if err != nil { + return fmt.Errorf("failed to grant COMMITTEE role to %s: %w", addr.StringLE(), err) + } + } + + return nil +} + +// InitializeCache initializes RoleRegistry cache from DAO. +func (r *RoleRegistry) InitializeCache(_ interop.IsHardforkEnabled, blockHeight uint32, d *dao.Simple) error { + cache := &RoleRegistryCache{} + cache.roleCount = r.getRoleCounter(d) + d.SetCache(r.ID, cache) + return nil +} + +// OnPersist implements the Contract interface. +func (r *RoleRegistry) OnPersist(ic *interop.Context) error { + return nil +} + +// PostPersist implements the Contract interface. +func (r *RoleRegistry) PostPersist(ic *interop.Context) error { + return nil +} + +// Metadata returns contract metadata. +func (r *RoleRegistry) Metadata() *interop.ContractMD { + return &r.ContractMD +} + +// ActiveIn implements the Contract interface. +func (r *RoleRegistry) ActiveIn() *config.Hardfork { + return nil +} + +// CheckCommittee returns true if the caller has the COMMITTEE role. +// This replaces NEO.CheckCommittee() throughout Tutus for democratic governance. +// Falls back to NEO.CheckCommittee() for backwards compatibility with StandbyCommittee. +func (r *RoleRegistry) CheckCommittee(ic *interop.Context) bool { + caller := ic.VM.GetCallingScriptHash() + + // Check if caller has COMMITTEE role in RoleRegistry + if r.HasRoleInternal(ic.DAO, caller, RoleCommittee, ic.Block.Index) { + return true + } + + // Check if caller is in the tutusCommittee config + for _, addr := range r.tutusCommittee { + if addr.Equals(caller) { + return true + } + } + + // Fallback to NEO.CheckCommittee for backwards compatibility + // This allows StandbyCommittee to work when TutusCommittee is not configured + if r.NEO != nil && r.NEO.CheckCommittee(ic) { + return true + } + + return false +} + +// CheckCommitteeWitness checks if the caller has committee signature. +func (r *RoleRegistry) checkCommitteeWitness(ic *interop.Context) error { + if !r.CheckCommittee(ic) { + // Fall back to checking if any TutusCommittee member signed the transaction + for _, addr := range r.tutusCommittee { + if ok, _ := runtime.CheckHashedWitness(ic, addr); ok { + return nil + } + } + // Check addresses that have been granted COMMITTEE role + committeeAddrs := r.getAddressesWithRole(ic.DAO, RoleCommittee) + for _, addr := range committeeAddrs { + if ok, _ := runtime.CheckHashedWitness(ic, addr); ok { + return nil + } + } + // Fallback to NEO.CheckCommittee for backwards compatibility + // This allows StandbyCommittee to work when TutusCommittee is not configured + if r.NEO != nil && r.NEO.CheckCommittee(ic) { + return nil + } + return ErrRoleRegistryNotCommittee + } + return nil +} + +// HasRoleInternal checks if an address has a specific role (including hierarchy). +// A user has a role if they have it directly, OR if they have a child role that inherits from it. +func (r *RoleRegistry) HasRoleInternal(d *dao.Simple, address util.Uint160, roleID uint64, blockHeight uint32) bool { + // Get all roles directly assigned to this address + assignedRoleIDs := r.getAddressRoleIDs(d, address) + + for _, assignedRoleID := range assignedRoleIDs { + // Check if assignment is valid + assignment := r.getAddressRoleAssignment(d, address, assignedRoleID) + if assignment == nil || !assignment.Active { + continue + } + if assignment.ExpiresAt != 0 && assignment.ExpiresAt <= blockHeight { + continue + } + + // Check if this assigned role equals the queried role, or inherits from it + if r.roleInheritsFrom(d, assignedRoleID, roleID) { + return true + } + } + + return false +} + +// roleInheritsFrom checks if roleID equals targetRoleID, or if roleID has targetRoleID as an ancestor. +func (r *RoleRegistry) roleInheritsFrom(d *dao.Simple, roleID, targetRoleID uint64) bool { + // Direct match + if roleID == targetRoleID { + return true + } + + // Traverse up the hierarchy + role := r.getRoleInternal(d, roleID) + if role != nil && role.ParentID != 0 { + return r.roleInheritsFrom(d, role.ParentID, targetRoleID) + } + + return false +} + +// HasPermissionInternal checks if an address has a specific permission via roles. +func (r *RoleRegistry) HasPermissionInternal(d *dao.Simple, address util.Uint160, resource, action string, scope state.Scope, blockHeight uint32) bool { + // Get all roles for this address + roleIDs := r.getAddressRoleIDs(d, address) + + for _, roleID := range roleIDs { + // Check if assignment is valid + assignment := r.getAddressRoleAssignment(d, address, roleID) + if assignment == nil || !assignment.Active { + continue + } + if assignment.ExpiresAt != 0 && assignment.ExpiresAt <= blockHeight { + continue + } + + // Check if role has the permission + if r.roleHasPermission(d, roleID, resource, action, scope) { + return true + } + } + + return false +} + +// roleHasPermission checks if a role (or its parents) has a specific permission. +func (r *RoleRegistry) roleHasPermission(d *dao.Simple, roleID uint64, resource, action string, scope state.Scope) bool { + // COMMITTEE role has all permissions + if roleID == RoleCommittee { + return true + } + + // Check direct permission + perm := r.getPermission(d, roleID, resource, action) + if perm != nil && perm.Scope <= scope { + return true + } + + // Check parent roles + role := r.getRoleInternal(d, roleID) + if role != nil && role.ParentID != 0 { + return r.roleHasPermission(d, role.ParentID, resource, action, scope) + } + + return false +} + +// Storage helpers + +func (r *RoleRegistry) makeRoleKey(roleID uint64) []byte { + key := make([]byte, 9) + key[0] = rrPrefixRole + binary.BigEndian.PutUint64(key[1:], roleID) + return key +} + +func (r *RoleRegistry) makeRoleNameKey(name string) []byte { + key := make([]byte, 1+len(name)) + key[0] = rrPrefixRoleName + copy(key[1:], []byte(name)) + return key +} + +func (r *RoleRegistry) makeAddressRoleKey(address util.Uint160, roleID uint64) []byte { + key := make([]byte, 1+20+8) + key[0] = rrPrefixAddressAssignment + copy(key[1:21], address.BytesBE()) + binary.BigEndian.PutUint64(key[21:], roleID) + return key +} + +func (r *RoleRegistry) makeAddressRolesKey(address util.Uint160) []byte { + key := make([]byte, 1+20) + key[0] = rrPrefixAddressRoles + copy(key[1:], address.BytesBE()) + return key +} + +func (r *RoleRegistry) makePermissionKey(roleID uint64, resource, action string) []byte { + key := make([]byte, 1+8+len(resource)+1+len(action)) + key[0] = rrPrefixPermission + binary.BigEndian.PutUint64(key[1:9], roleID) + copy(key[9:9+len(resource)], resource) + key[9+len(resource)] = 0 // separator + copy(key[10+len(resource):], action) + return key +} + +func (r *RoleRegistry) putRole(d *dao.Simple, role *state.Role) error { + key := r.makeRoleKey(role.ID) + return putConvertibleToDAO(r.ID, d, key, role) +} + +func (r *RoleRegistry) getRoleInternal(d *dao.Simple, roleID uint64) *state.Role { + key := r.makeRoleKey(roleID) + si := d.GetStorageItem(r.ID, key) + if si == nil { + return nil + } + role := new(state.Role) + err := stackitem.DeserializeConvertible(si, role) + if err != nil { + return nil + } + return role +} + +func (r *RoleRegistry) putRoleNameIndex(d *dao.Simple, name string, roleID uint64) { + key := r.makeRoleNameKey(name) + data := make([]byte, 8) + binary.BigEndian.PutUint64(data, roleID) + d.PutStorageItem(r.ID, key, data) +} + +func (r *RoleRegistry) getRoleIDByName(d *dao.Simple, name string) (uint64, bool) { + key := r.makeRoleNameKey(name) + si := d.GetStorageItem(r.ID, key) + if si == nil { + return 0, false + } + return binary.BigEndian.Uint64(si), true +} + +func (r *RoleRegistry) putAddressRoleAssignment(d *dao.Simple, assignment *state.AddressRoleAssignment) error { + key := r.makeAddressRoleKey(assignment.Address, assignment.RoleID) + err := putConvertibleToDAO(r.ID, d, key, assignment) + if err != nil { + return err + } + // Update address roles index + r.addAddressRoleIndex(d, assignment.Address, assignment.RoleID) + return nil +} + +func (r *RoleRegistry) getAddressRoleAssignment(d *dao.Simple, address util.Uint160, roleID uint64) *state.AddressRoleAssignment { + key := r.makeAddressRoleKey(address, roleID) + si := d.GetStorageItem(r.ID, key) + if si == nil { + return nil + } + assignment := new(state.AddressRoleAssignment) + err := stackitem.DeserializeConvertible(si, assignment) + if err != nil { + return nil + } + return assignment +} + +func (r *RoleRegistry) deleteAddressRoleAssignment(d *dao.Simple, address util.Uint160, roleID uint64) { + key := r.makeAddressRoleKey(address, roleID) + d.DeleteStorageItem(r.ID, key) + r.removeAddressRoleIndex(d, address, roleID) +} + +func (r *RoleRegistry) addAddressRoleIndex(d *dao.Simple, address util.Uint160, roleID uint64) { + key := r.makeAddressRolesKey(address) + si := d.GetStorageItem(r.ID, key) + var roleIDs []uint64 + if si != nil { + roleIDs = r.decodeRoleIDs(si) + } + // Check if already exists + for _, id := range roleIDs { + if id == roleID { + return + } + } + roleIDs = append(roleIDs, roleID) + d.PutStorageItem(r.ID, key, r.encodeRoleIDs(roleIDs)) +} + +func (r *RoleRegistry) removeAddressRoleIndex(d *dao.Simple, address util.Uint160, roleID uint64) { + key := r.makeAddressRolesKey(address) + si := d.GetStorageItem(r.ID, key) + if si == nil { + return + } + roleIDs := r.decodeRoleIDs(si) + newIDs := make([]uint64, 0, len(roleIDs)) + for _, id := range roleIDs { + if id != roleID { + newIDs = append(newIDs, id) + } + } + if len(newIDs) == 0 { + d.DeleteStorageItem(r.ID, key) + } else { + d.PutStorageItem(r.ID, key, r.encodeRoleIDs(newIDs)) + } +} + +func (r *RoleRegistry) getAddressRoleIDs(d *dao.Simple, address util.Uint160) []uint64 { + key := r.makeAddressRolesKey(address) + si := d.GetStorageItem(r.ID, key) + if si == nil { + return nil + } + return r.decodeRoleIDs(si) +} + +func (r *RoleRegistry) getAddressesWithRole(d *dao.Simple, roleID uint64) []util.Uint160 { + var addresses []util.Uint160 + prefix := []byte{rrPrefixAddressAssignment} + d.Seek(r.ID, storage.SeekRange{Prefix: prefix}, func(k, v []byte) bool { + if len(k) >= 28 { + storedRoleID := binary.BigEndian.Uint64(k[20:28]) + if storedRoleID == roleID { + addr, err := util.Uint160DecodeBytesBE(k[:20]) + if err == nil { + assignment := new(state.AddressRoleAssignment) + if err := stackitem.DeserializeConvertible(v, assignment); err == nil && assignment.Active { + addresses = append(addresses, addr) + } + } + } + } + return true + }) + return addresses +} + +func (r *RoleRegistry) encodeRoleIDs(ids []uint64) []byte { + data := make([]byte, 8*len(ids)) + for i, id := range ids { + binary.BigEndian.PutUint64(data[i*8:], id) + } + return data +} + +func (r *RoleRegistry) decodeRoleIDs(data []byte) []uint64 { + count := len(data) / 8 + ids := make([]uint64, count) + for i := 0; i < count; i++ { + ids[i] = binary.BigEndian.Uint64(data[i*8:]) + } + return ids +} + +func (r *RoleRegistry) putPermission(d *dao.Simple, perm *state.PermissionGrant) error { + key := r.makePermissionKey(perm.RoleID, perm.Resource, perm.Action) + return putConvertibleToDAO(r.ID, d, key, perm) +} + +func (r *RoleRegistry) getPermission(d *dao.Simple, roleID uint64, resource, action string) *state.PermissionGrant { + key := r.makePermissionKey(roleID, resource, action) + si := d.GetStorageItem(r.ID, key) + if si == nil { + return nil + } + perm := new(state.PermissionGrant) + err := stackitem.DeserializeConvertible(si, perm) + if err != nil { + return nil + } + return perm +} + +func (r *RoleRegistry) deletePermission(d *dao.Simple, roleID uint64, resource, action string) { + key := r.makePermissionKey(roleID, resource, action) + d.DeleteStorageItem(r.ID, key) +} + +func (r *RoleRegistry) getRoleCounter(d *dao.Simple) uint64 { + key := []byte{rrPrefixCounter} + si := d.GetStorageItem(r.ID, key) + if si == nil { + return 0 + } + return binary.BigEndian.Uint64(si) +} + +func (r *RoleRegistry) putRoleCounter(d *dao.Simple, count uint64) { + key := []byte{rrPrefixCounter} + data := make([]byte, 8) + binary.BigEndian.PutUint64(data, count) + d.PutStorageItem(r.ID, key, data) +} + +func (r *RoleRegistry) nextRoleID(d *dao.Simple, cache *RoleRegistryCache) uint64 { + id := r.getRoleCounter(d) + r.putRoleCounter(d, id+1) + cache.roleCount++ + return id +} + +// Contract methods + +func (r *RoleRegistry) totalRoles(ic *interop.Context, args []stackitem.Item) stackitem.Item { + cache := ic.DAO.GetROCache(r.ID).(*RoleRegistryCache) + return stackitem.NewBigInteger(big.NewInt(int64(cache.roleCount))) +} + +func (r *RoleRegistry) getRole(ic *interop.Context, args []stackitem.Item) stackitem.Item { + roleID := toBigInt(args[0]).Uint64() + role := r.getRoleInternal(ic.DAO, roleID) + if role == nil { + return stackitem.Null{} + } + item, _ := role.ToStackItem() + return item +} + +func (r *RoleRegistry) getRoleByName(ic *interop.Context, args []stackitem.Item) stackitem.Item { + name := toString(args[0]) + roleID, found := r.getRoleIDByName(ic.DAO, name) + if !found { + return stackitem.Null{} + } + role := r.getRoleInternal(ic.DAO, roleID) + if role == nil { + return stackitem.Null{} + } + item, _ := role.ToStackItem() + return item +} + +func (r *RoleRegistry) hasRole(ic *interop.Context, args []stackitem.Item) stackitem.Item { + address := toUint160(args[0]) + roleID := toBigInt(args[1]).Uint64() + return stackitem.NewBool(r.HasRoleInternal(ic.DAO, address, roleID, ic.Block.Index)) +} + +func (r *RoleRegistry) getRolesForAddress(ic *interop.Context, args []stackitem.Item) stackitem.Item { + address := toUint160(args[0]) + roleIDs := r.getAddressRoleIDs(ic.DAO, address) + items := make([]stackitem.Item, len(roleIDs)) + for i, id := range roleIDs { + items[i] = stackitem.NewBigInteger(big.NewInt(int64(id))) + } + return stackitem.NewArray(items) +} + +func (r *RoleRegistry) getPermissions(ic *interop.Context, args []stackitem.Item) stackitem.Item { + roleID := toBigInt(args[0]).Uint64() + var perms []stackitem.Item + prefix := make([]byte, 9) + prefix[0] = rrPrefixPermission + binary.BigEndian.PutUint64(prefix[1:], roleID) + ic.DAO.Seek(r.ID, storage.SeekRange{Prefix: prefix}, func(k, v []byte) bool { + perm := new(state.PermissionGrant) + if err := stackitem.DeserializeConvertible(v, perm); err == nil { + item, _ := perm.ToStackItem() + perms = append(perms, item) + } + return true + }) + return stackitem.NewArray(perms) +} + +func (r *RoleRegistry) hasPermission(ic *interop.Context, args []stackitem.Item) stackitem.Item { + address := toUint160(args[0]) + resource := toString(args[1]) + action := toString(args[2]) + scopeInt := toBigInt(args[3]).Int64() + scope := state.Scope(scopeInt) + return stackitem.NewBool(r.HasPermissionInternal(ic.DAO, address, resource, action, scope, ic.Block.Index)) +} + +func (r *RoleRegistry) createRole(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if err := r.checkCommitteeWitness(ic); err != nil { + panic(err) + } + + name := toString(args[0]) + description := toString(args[1]) + parentID := toBigInt(args[2]).Uint64() + + // Validation + if len(name) > maxRoleNameLength { + panic(ErrRoleNameTooLong) + } + if len(description) > maxRoleDescriptionLength { + panic(ErrRoleDescTooLong) + } + if _, exists := r.getRoleIDByName(ic.DAO, name); exists { + panic(ErrRoleNameExists) + } + if parentID != 0 { + parent := r.getRoleInternal(ic.DAO, parentID) + if parent == nil { + panic(ErrInvalidParentRole) + } + if !parent.Active { + panic(ErrRoleNotActive) + } + } + + cache := ic.DAO.GetRWCache(r.ID).(*RoleRegistryCache) + roleID := r.nextRoleID(ic.DAO, cache) + + caller := ic.VM.GetCallingScriptHash() + role := &state.Role{ + ID: roleID, + Name: name, + Description: description, + ParentID: parentID, + CreatedAt: ic.Block.Index, + CreatedBy: caller, + Active: true, + } + + if err := r.putRole(ic.DAO, role); err != nil { + panic(err) + } + r.putRoleNameIndex(ic.DAO, name, roleID) + + err := ic.AddNotification(r.Hash, RoleCreatedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(roleID))), + stackitem.NewByteArray([]byte(name)), + stackitem.NewBigInteger(big.NewInt(int64(parentID))), + stackitem.NewByteArray(caller.BytesBE()), + })) + if err != nil { + panic(err) + } + + return stackitem.NewBigInteger(big.NewInt(int64(roleID))) +} + +func (r *RoleRegistry) deleteRole(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if err := r.checkCommitteeWitness(ic); err != nil { + panic(err) + } + + roleID := toBigInt(args[0]).Uint64() + + // Cannot delete built-in roles + if roleID <= RoleOperator { + panic(ErrBuiltinRole) + } + + role := r.getRoleInternal(ic.DAO, roleID) + if role == nil { + panic(ErrRoleNotFound) + } + + role.Active = false + if err := r.putRole(ic.DAO, role); err != nil { + panic(err) + } + + caller := ic.VM.GetCallingScriptHash() + err := ic.AddNotification(r.Hash, RoleDeletedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(roleID))), + stackitem.NewByteArray(caller.BytesBE()), + })) + if err != nil { + panic(err) + } + + return stackitem.NewBool(true) +} + +func (r *RoleRegistry) grantRole(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if err := r.checkCommitteeWitness(ic); err != nil { + panic(err) + } + + address := toUint160(args[0]) + roleID := toBigInt(args[1]).Uint64() + expiresAt := uint32(toBigInt(args[2]).Uint64()) + + // Verify role exists and is active + role := r.getRoleInternal(ic.DAO, roleID) + if role == nil { + panic(ErrRoleNotFound) + } + if !role.Active { + panic(ErrRoleNotActive) + } + + // Check if already assigned + existing := r.getAddressRoleAssignment(ic.DAO, address, roleID) + if existing != nil && existing.Active { + panic(ErrAssignmentExists) + } + + caller := ic.VM.GetCallingScriptHash() + assignment := &state.AddressRoleAssignment{ + Address: address, + RoleID: roleID, + GrantedAt: ic.Block.Index, + GrantedBy: caller, + ExpiresAt: expiresAt, + Active: true, + } + + if err := r.putAddressRoleAssignment(ic.DAO, assignment); err != nil { + panic(err) + } + + err := ic.AddNotification(r.Hash, RoleGrantedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(address.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(roleID))), + stackitem.NewBigInteger(big.NewInt(int64(expiresAt))), + stackitem.NewByteArray(caller.BytesBE()), + })) + if err != nil { + panic(err) + } + + return stackitem.NewBool(true) +} + +func (r *RoleRegistry) revokeRole(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if err := r.checkCommitteeWitness(ic); err != nil { + panic(err) + } + + address := toUint160(args[0]) + roleID := toBigInt(args[1]).Uint64() + + assignment := r.getAddressRoleAssignment(ic.DAO, address, roleID) + if assignment == nil || !assignment.Active { + panic(ErrAssignmentNotFound) + } + + assignment.Active = false + if err := r.putAddressRoleAssignment(ic.DAO, assignment); err != nil { + panic(err) + } + + caller := ic.VM.GetCallingScriptHash() + err := ic.AddNotification(r.Hash, RoleRevokedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(address.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(roleID))), + stackitem.NewByteArray(caller.BytesBE()), + })) + if err != nil { + panic(err) + } + + return stackitem.NewBool(true) +} + +func (r *RoleRegistry) assignPermission(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if err := r.checkCommitteeWitness(ic); err != nil { + panic(err) + } + + roleID := toBigInt(args[0]).Uint64() + resource := toString(args[1]) + action := toString(args[2]) + scopeInt := toBigInt(args[3]).Int64() + scope := state.Scope(scopeInt) + + // Validation + if len(resource) > maxResourceLength { + panic(ErrResourceTooLong) + } + if len(action) > maxActionLength { + panic(ErrActionTooLong) + } + if scope > state.ScopeDelegated { + panic(ErrInvalidScope) + } + + // Verify role exists and is active + role := r.getRoleInternal(ic.DAO, roleID) + if role == nil { + panic(ErrRoleNotFound) + } + if !role.Active { + panic(ErrRoleNotActive) + } + + // Check if already exists + existing := r.getPermission(ic.DAO, roleID, resource, action) + if existing != nil { + panic(ErrPermissionExists) + } + + caller := ic.VM.GetCallingScriptHash() + perm := &state.PermissionGrant{ + RoleID: roleID, + Resource: resource, + Action: action, + Scope: scope, + GrantedAt: ic.Block.Index, + GrantedBy: caller, + } + + if err := r.putPermission(ic.DAO, perm); err != nil { + panic(err) + } + + err := ic.AddNotification(r.Hash, PermissionAssignedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(roleID))), + stackitem.NewByteArray([]byte(resource)), + stackitem.NewByteArray([]byte(action)), + stackitem.NewBigInteger(big.NewInt(int64(scope))), + })) + if err != nil { + panic(err) + } + + return stackitem.NewBool(true) +} + +func (r *RoleRegistry) removePermission(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if err := r.checkCommitteeWitness(ic); err != nil { + panic(err) + } + + roleID := toBigInt(args[0]).Uint64() + resource := toString(args[1]) + action := toString(args[2]) + + // Verify permission exists + existing := r.getPermission(ic.DAO, roleID, resource, action) + if existing == nil { + panic(ErrPermissionNotFound) + } + + r.deletePermission(ic.DAO, roleID, resource, action) + + err := ic.AddNotification(r.Hash, PermissionRemovedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(roleID))), + stackitem.NewByteArray([]byte(resource)), + stackitem.NewByteArray([]byte(action)), + })) + if err != nil { + panic(err) + } + + return stackitem.NewBool(true) +} diff --git a/pkg/core/state/role_registry.go b/pkg/core/state/role_registry.go new file mode 100644 index 0000000..f5db01f --- /dev/null +++ b/pkg/core/state/role_registry.go @@ -0,0 +1,327 @@ +package state + +import ( + "errors" + "fmt" + "math/big" + + "github.com/tutus-one/tutus-chain/pkg/util" + "github.com/tutus-one/tutus-chain/pkg/vm/stackitem" +) + +// Scope represents permission scope level. +type Scope uint8 + +const ( + // ScopeGlobal applies the permission globally. + ScopeGlobal Scope = 0 + // ScopePersonal applies only to the owner's own resources. + ScopePersonal Scope = 1 + // ScopeDelegated is delegated by another token holder. + ScopeDelegated Scope = 2 +) + +// Role represents a custom role definition in the RoleRegistry. +type Role struct { + ID uint64 // Unique sequential identifier + Name string // Human-readable name (max 64 chars) + Description string // Description (max 256 chars) + ParentID uint64 // Parent role ID (0 = no parent, enables hierarchy) + CreatedAt uint32 // Block height when created + CreatedBy util.Uint160 // Creator's script hash + Active bool // Whether role is active +} + +// ToStackItem implements stackitem.Convertible interface. +func (r *Role) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(r.ID))), + stackitem.NewByteArray([]byte(r.Name)), + stackitem.NewByteArray([]byte(r.Description)), + stackitem.NewBigInteger(big.NewInt(int64(r.ParentID))), + stackitem.NewBigInteger(big.NewInt(int64(r.CreatedAt))), + stackitem.NewByteArray(r.CreatedBy.BytesBE()), + stackitem.NewBool(r.Active), + }), nil +} + +// FromStackItem implements stackitem.Convertible interface. +func (r *Role) FromStackItem(item stackitem.Item) error { + items, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not a struct") + } + if len(items) != 7 { + return fmt.Errorf("wrong number of elements: expected 7, got %d", len(items)) + } + + id, err := items[0].TryInteger() + if err != nil { + return fmt.Errorf("invalid id: %w", err) + } + r.ID = id.Uint64() + + nameBytes, err := items[1].TryBytes() + if err != nil { + return fmt.Errorf("invalid name: %w", err) + } + r.Name = string(nameBytes) + + descBytes, err := items[2].TryBytes() + if err != nil { + return fmt.Errorf("invalid description: %w", err) + } + r.Description = string(descBytes) + + parentID, err := items[3].TryInteger() + if err != nil { + return fmt.Errorf("invalid parentID: %w", err) + } + r.ParentID = parentID.Uint64() + + createdAt, err := items[4].TryInteger() + if err != nil { + return fmt.Errorf("invalid createdAt: %w", err) + } + r.CreatedAt = uint32(createdAt.Int64()) + + createdByBytes, err := items[5].TryBytes() + if err != nil { + return fmt.Errorf("invalid createdBy: %w", err) + } + r.CreatedBy, err = util.Uint160DecodeBytesBE(createdByBytes) + if err != nil { + return fmt.Errorf("invalid createdBy hash: %w", err) + } + + r.Active, err = items[6].TryBool() + if err != nil { + return fmt.Errorf("invalid active: %w", err) + } + + return nil +} + +// RoleAssignment represents a role granted to a PersonToken holder. +type RoleAssignment struct { + TokenID uint64 // PersonToken ID (or script hash as uint64 for address-based) + RoleID uint64 // Role ID + GrantedAt uint32 // Block height when granted + GrantedBy util.Uint160 // Granter's script hash + ExpiresAt uint32 // Expiration block (0 = never) + Active bool // Whether assignment is active +} + +// ToStackItem implements stackitem.Convertible interface. +func (a *RoleAssignment) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(a.TokenID))), + stackitem.NewBigInteger(big.NewInt(int64(a.RoleID))), + stackitem.NewBigInteger(big.NewInt(int64(a.GrantedAt))), + stackitem.NewByteArray(a.GrantedBy.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(a.ExpiresAt))), + stackitem.NewBool(a.Active), + }), nil +} + +// FromStackItem implements stackitem.Convertible interface. +func (a *RoleAssignment) FromStackItem(item stackitem.Item) error { + items, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not a struct") + } + if len(items) != 6 { + return fmt.Errorf("wrong number of elements: expected 6, got %d", len(items)) + } + + tokenID, err := items[0].TryInteger() + if err != nil { + return fmt.Errorf("invalid tokenID: %w", err) + } + a.TokenID = tokenID.Uint64() + + roleID, err := items[1].TryInteger() + if err != nil { + return fmt.Errorf("invalid roleID: %w", err) + } + a.RoleID = roleID.Uint64() + + grantedAt, err := items[2].TryInteger() + if err != nil { + return fmt.Errorf("invalid grantedAt: %w", err) + } + a.GrantedAt = uint32(grantedAt.Int64()) + + grantedByBytes, err := items[3].TryBytes() + if err != nil { + return fmt.Errorf("invalid grantedBy: %w", err) + } + a.GrantedBy, err = util.Uint160DecodeBytesBE(grantedByBytes) + if err != nil { + return fmt.Errorf("invalid grantedBy hash: %w", err) + } + + expiresAt, err := items[4].TryInteger() + if err != nil { + return fmt.Errorf("invalid expiresAt: %w", err) + } + a.ExpiresAt = uint32(expiresAt.Int64()) + + a.Active, err = items[5].TryBool() + if err != nil { + return fmt.Errorf("invalid active: %w", err) + } + + return nil +} + +// PermissionGrant represents a permission assigned to a role. +type PermissionGrant struct { + RoleID uint64 // Role ID + Resource string // Resource identifier (e.g., "documents") + Action string // Action identifier (e.g., "read", "write") + Scope Scope // Scope level + GrantedAt uint32 // Block height when granted + GrantedBy util.Uint160 // Granter's script hash +} + +// ToStackItem implements stackitem.Convertible interface. +func (p *PermissionGrant) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(p.RoleID))), + stackitem.NewByteArray([]byte(p.Resource)), + stackitem.NewByteArray([]byte(p.Action)), + stackitem.NewBigInteger(big.NewInt(int64(p.Scope))), + stackitem.NewBigInteger(big.NewInt(int64(p.GrantedAt))), + stackitem.NewByteArray(p.GrantedBy.BytesBE()), + }), nil +} + +// FromStackItem implements stackitem.Convertible interface. +func (p *PermissionGrant) FromStackItem(item stackitem.Item) error { + items, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not a struct") + } + if len(items) != 6 { + return fmt.Errorf("wrong number of elements: expected 6, got %d", len(items)) + } + + roleID, err := items[0].TryInteger() + if err != nil { + return fmt.Errorf("invalid roleID: %w", err) + } + p.RoleID = roleID.Uint64() + + resourceBytes, err := items[1].TryBytes() + if err != nil { + return fmt.Errorf("invalid resource: %w", err) + } + p.Resource = string(resourceBytes) + + actionBytes, err := items[2].TryBytes() + if err != nil { + return fmt.Errorf("invalid action: %w", err) + } + p.Action = string(actionBytes) + + scope, err := items[3].TryInteger() + if err != nil { + return fmt.Errorf("invalid scope: %w", err) + } + p.Scope = Scope(scope.Int64()) + + grantedAt, err := items[4].TryInteger() + if err != nil { + return fmt.Errorf("invalid grantedAt: %w", err) + } + p.GrantedAt = uint32(grantedAt.Int64()) + + grantedByBytes, err := items[5].TryBytes() + if err != nil { + return fmt.Errorf("invalid grantedBy: %w", err) + } + p.GrantedBy, err = util.Uint160DecodeBytesBE(grantedByBytes) + if err != nil { + return fmt.Errorf("invalid grantedBy hash: %w", err) + } + + return nil +} + +// AddressRoleAssignment represents a role granted directly to an address (script hash). +// This is used for bootstrapping when PersonTokens may not exist yet. +type AddressRoleAssignment struct { + Address util.Uint160 // Script hash of the address + RoleID uint64 // Role ID + GrantedAt uint32 // Block height when granted + GrantedBy util.Uint160 // Granter's script hash + ExpiresAt uint32 // Expiration block (0 = never) + Active bool // Whether assignment is active +} + +// ToStackItem implements stackitem.Convertible interface. +func (a *AddressRoleAssignment) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray(a.Address.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(a.RoleID))), + stackitem.NewBigInteger(big.NewInt(int64(a.GrantedAt))), + stackitem.NewByteArray(a.GrantedBy.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(a.ExpiresAt))), + stackitem.NewBool(a.Active), + }), nil +} + +// FromStackItem implements stackitem.Convertible interface. +func (a *AddressRoleAssignment) FromStackItem(item stackitem.Item) error { + items, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not a struct") + } + if len(items) != 6 { + return fmt.Errorf("wrong number of elements: expected 6, got %d", len(items)) + } + + addressBytes, err := items[0].TryBytes() + if err != nil { + return fmt.Errorf("invalid address: %w", err) + } + a.Address, err = util.Uint160DecodeBytesBE(addressBytes) + if err != nil { + return fmt.Errorf("invalid address hash: %w", err) + } + + roleID, err := items[1].TryInteger() + if err != nil { + return fmt.Errorf("invalid roleID: %w", err) + } + a.RoleID = roleID.Uint64() + + grantedAt, err := items[2].TryInteger() + if err != nil { + return fmt.Errorf("invalid grantedAt: %w", err) + } + a.GrantedAt = uint32(grantedAt.Int64()) + + grantedByBytes, err := items[3].TryBytes() + if err != nil { + return fmt.Errorf("invalid grantedBy: %w", err) + } + a.GrantedBy, err = util.Uint160DecodeBytesBE(grantedByBytes) + if err != nil { + return fmt.Errorf("invalid grantedBy hash: %w", err) + } + + expiresAt, err := items[4].TryInteger() + if err != nil { + return fmt.Errorf("invalid expiresAt: %w", err) + } + a.ExpiresAt = uint32(expiresAt.Int64()) + + a.Active, err = items[5].TryBool() + if err != nil { + return fmt.Errorf("invalid active: %w", err) + } + + return nil +}