diff --git a/pkg/core/native/contract.go b/pkg/core/native/contract.go index fb9fc44..b36c4a0 100644 --- a/pkg/core/native/contract.go +++ b/pkg/core/native/contract.go @@ -110,6 +110,16 @@ type ( ExpirationOf(dao *dao.Simple, acc util.Uint160) uint32 GetMaxNotValidBeforeDelta(dao *dao.Simple) uint32 } + + // IPersonToken is an interface required from native PersonToken contract + // for interaction with Blockchain and other native contracts. + IPersonToken interface { + interop.Contract + GetTokenByOwner(d *dao.Simple, owner util.Uint160) (*state.PersonToken, error) + GetTokenByIDPublic(d *dao.Simple, tokenID uint64) (*state.PersonToken, error) + TokenExists(d *dao.Simple, owner util.Uint160) bool + GetAttribute(d *dao.Simple, tokenID uint64, key string) (*state.Attribute, error) + } ) // Contracts is a convenient wrapper around an arbitrary set of native contracts @@ -225,6 +235,12 @@ func (cs *Contracts) Notary() INotary { return nil } +// PersonToken returns native IPersonToken contract implementation. It panics if +// there's no contract with proper name in cs. +func (cs *Contracts) PersonToken() IPersonToken { + return cs.ByName(nativenames.PersonToken).(IPersonToken) +} + // NewDefaultContracts returns a new set of default native contracts. func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { mgmt := NewManagement() @@ -261,6 +277,9 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { treasury := newTreasury() treasury.NEO = neo + personToken := newPersonToken() + personToken.NEO = neo + return []interop.Contract{ mgmt, s, @@ -273,5 +292,6 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { oracle, notary, treasury, + personToken, } } diff --git a/pkg/core/native/native_test/management_test.go b/pkg/core/native/native_test/management_test.go index 41feac5..386b38a 100644 --- a/pkg/core/native/native_test/management_test.go +++ b/pkg/core/native/native_test/management_test.go @@ -51,6 +51,7 @@ 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}`, } // 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/person_token_test.go b/pkg/core/native/native_test/person_token_test.go new file mode 100644 index 0000000..7a338d8 --- /dev/null +++ b/pkg/core/native/native_test/person_token_test.go @@ -0,0 +1,290 @@ +package native_test + +import ( + "testing" + + "github.com/nspcc-dev/neo-go/pkg/crypto/hash" + "github.com/stretchr/testify/require" + "github.com/tutus-one/tutus-chain/pkg/core/native/nativenames" + "github.com/tutus-one/tutus-chain/pkg/core/state" + "github.com/tutus-one/tutus-chain/pkg/neotest" + "github.com/tutus-one/tutus-chain/pkg/vm/stackitem" +) + +func newPersonTokenClient(t *testing.T) *neotest.ContractInvoker { + return newNativeClient(t, nativenames.PersonToken) +} + +// registerPersonToken is a helper to register a PersonToken for a signer. +// Returns the tokenID bytes. +func registerPersonToken(t *testing.T, c *neotest.ContractInvoker, signer neotest.Signer) []byte { + owner := signer.ScriptHash() + personHash := hash.Sha256(owner.BytesBE()).BytesBE() + isEntity := false + recoveryHash := hash.Sha256([]byte("recovery")).BytesBE() + + invoker := c.WithSigners(signer) + // Register returns tokenID bytes, not null + txHash := invoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + // Result is a ByteArray (tokenID) + _, ok := stack[0].Value().([]byte) + require.True(t, ok, "expected ByteArray result") + }, "register", owner.BytesBE(), personHash, isEntity, recoveryHash) + + aer := c.Executor.GetTxExecResult(t, txHash) + require.Equal(t, 1, len(aer.Stack)) + tokenIDBytes := aer.Stack[0].Value().([]byte) + return tokenIDBytes +} + +// TestPersonToken_Register tests basic registration functionality. +func TestPersonToken_Register(t *testing.T) { + c := newPersonTokenClient(t) + e := c.Executor + + acc := e.NewAccount(t) + owner := acc.ScriptHash() + personHash := hash.Sha256(owner.BytesBE()).BytesBE() + isEntity := false + recoveryHash := hash.Sha256([]byte("recovery")).BytesBE() + + invoker := c.WithSigners(acc) + + // Register token - returns tokenID bytes + txHash := invoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + _, ok := stack[0].Value().([]byte) + require.True(t, ok, "expected ByteArray result") + }, "register", owner.BytesBE(), personHash, isEntity, recoveryHash) + + // Check event was emitted + aer := e.GetTxExecResult(t, txHash) + require.Equal(t, 1, len(aer.Events)) + require.Equal(t, "PersonTokenCreated", aer.Events[0].Name) + + // Check exists returns true + invoker.Invoke(t, true, "exists", owner.BytesBE()) + + // Check getToken returns valid token + invoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr := stack[0].Value().([]stackitem.Item) + require.GreaterOrEqual(t, len(arr), 5) // At least tokenID, owner, personHash, isEntity, status + }, "getToken", owner.BytesBE()) +} + +// TestPersonToken_ValidateCaller tests the validateCaller method. +// Note: validateCaller uses GetCallingScriptHash() which returns the calling contract's +// script hash, not the transaction signer's address. When called directly from a transaction +// (not from another contract), the caller has no token. These methods are designed for +// cross-contract authorization. +func TestPersonToken_ValidateCaller(t *testing.T) { + c := newPersonTokenClient(t) + + t.Run("no token - direct call", func(t *testing.T) { + acc := c.Executor.NewAccount(t) + invoker := c.WithSigners(acc) + + // validateCaller uses GetCallingScriptHash() which returns the transaction script hash + // when called directly, not the signer's account. This will always fail for direct calls. + invoker.InvokeFail(t, "caller does not have a PersonToken", "validateCaller") + }) + + // Note: Testing validateCaller with a token requires deploying a helper contract + // that has a PersonToken registered to its script hash, then calling validateCaller + // from within that contract. This is the intended usage pattern for cross-contract auth. +} + +// TestPersonToken_RequireRole tests the requireRole method. +// Note: requireRole uses GetCallingScriptHash() - designed for cross-contract authorization. +func TestPersonToken_RequireRole(t *testing.T) { + c := newPersonTokenClient(t) + + t.Run("no token - direct call", func(t *testing.T) { + acc := c.Executor.NewAccount(t) + invoker := c.WithSigners(acc) + + // Direct calls always fail because GetCallingScriptHash() returns transaction script hash + invoker.InvokeFail(t, "caller does not have a PersonToken", "requireRole", 0) + }) + + // Note: Testing requireRole with actual role checks requires a deployed contract + // with a PersonToken registered to its script hash. +} + +// TestPersonToken_RequireCoreRole tests the requireCoreRole method. +// Note: requireCoreRole uses GetCallingScriptHash() - designed for cross-contract authorization. +func TestPersonToken_RequireCoreRole(t *testing.T) { + c := newPersonTokenClient(t) + + // CoreRole constants + const ( + CoreRoleNone = 0 + CoreRoleRecovery = 5 + ) + + t.Run("invalid role", func(t *testing.T) { + acc := c.Executor.NewAccount(t) + invoker := c.WithSigners(acc) + + // requireCoreRole with invalid role (> 5) should fail before checking token + invoker.InvokeFail(t, "invalid core role", "requireCoreRole", 10) + }) + + t.Run("no token - direct call", func(t *testing.T) { + acc := c.Executor.NewAccount(t) + invoker := c.WithSigners(acc) + + // Direct calls always fail because GetCallingScriptHash() returns transaction script hash + invoker.InvokeFail(t, "caller does not have a PersonToken", "requireCoreRole", CoreRoleNone) + }) + + // Note: Testing requireCoreRole with actual role checks requires a deployed contract + // with a PersonToken registered to its script hash. +} + +// TestPersonToken_RequirePermission tests the requirePermission method. +// Note: requirePermission uses GetCallingScriptHash() - designed for cross-contract authorization. +func TestPersonToken_RequirePermission(t *testing.T) { + c := newPersonTokenClient(t) + + t.Run("empty resource", func(t *testing.T) { + acc := c.Executor.NewAccount(t) + invoker := c.WithSigners(acc) + + // requirePermission with empty resource should fail before checking token + invoker.InvokeFail(t, "invalid resource", "requirePermission", "", "read", "global") + }) + + t.Run("empty action", func(t *testing.T) { + acc := c.Executor.NewAccount(t) + invoker := c.WithSigners(acc) + + // requirePermission with empty action should fail before checking token + invoker.InvokeFail(t, "invalid action", "requirePermission", "documents", "", "global") + }) + + t.Run("no token - direct call", func(t *testing.T) { + acc := c.Executor.NewAccount(t) + invoker := c.WithSigners(acc) + + // Direct calls always fail because GetCallingScriptHash() returns transaction script hash + invoker.InvokeFail(t, "caller does not have a PersonToken", "requirePermission", "documents", "read", "global") + }) + + // Note: Testing requirePermission with actual permission checks requires a deployed contract + // with a PersonToken registered to its script hash. +} + +// TestPersonToken_TotalSupply tests the totalSupply method. +func TestPersonToken_TotalSupply(t *testing.T) { + c := newPersonTokenClient(t) + e := c.Executor + + // Initially, totalSupply should be 0 + c.Invoke(t, 0, "totalSupply") + + // Register a token + acc1 := e.NewAccount(t) + registerPersonToken(t, c, acc1) + + // Now totalSupply should be 1 + c.Invoke(t, 1, "totalSupply") + + // Register another token + acc2 := e.NewAccount(t) + registerPersonToken(t, c, acc2) + + // Now totalSupply should be 2 + c.Invoke(t, 2, "totalSupply") +} + +// TestPersonToken_GetTokenByID tests the getTokenByID method. +func TestPersonToken_GetTokenByID(t *testing.T) { + c := newPersonTokenClient(t) + e := c.Executor + + // Register a token - the first token gets ID 0 (counter starts at 0) + acc := e.NewAccount(t) + registerPersonToken(t, c, acc) + + // Token ID 0 should exist (first registered token) + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + // Check that result is an array (not null) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array result for existing token") + require.GreaterOrEqual(t, len(arr), 9) // PersonToken has 9 fields + + // Check owner matches (owner is at index 1) + owner, ok := arr[1].Value().([]byte) + require.True(t, ok, "expected owner to be bytes") + require.Equal(t, acc.ScriptHash().BytesBE(), owner) + }, "getTokenByID", 0) + + // Non-existent token should return null + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + // Null returns nil from Value() + require.Nil(t, stack[0].Value(), "expected null for non-existent token") + }, "getTokenByID", 999999) +} + +// TestPersonToken_SuspendReinstate tests suspend and reinstate functionality. +func TestPersonToken_SuspendReinstate(t *testing.T) { + c := newPersonTokenClient(t) + e := c.Executor + + acc := e.NewAccount(t) + registerPersonToken(t, c, acc) + + invoker := c.WithSigners(acc) + committeeInvoker := c.WithSigners(c.Committee) + + // Initially token is active + invoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr := stack[0].Value().([]stackitem.Item) + // Status is at index 6 (after tokenID, owner, personHash, isEntity, createdAt, updatedAt) + status, err := arr[6].TryInteger() + require.NoError(t, err) + require.Equal(t, int64(state.TokenStatusActive), status.Int64()) + }, "getToken", acc.ScriptHash().BytesBE()) + + // Non-committee cannot suspend + invoker.InvokeFail(t, "invalid committee signature", "suspend", acc.ScriptHash().BytesBE(), "test") + + // Committee can suspend + committeeInvoker.Invoke(t, true, "suspend", acc.ScriptHash().BytesBE(), "test suspension") + + // Token is now suspended + invoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr := stack[0].Value().([]stackitem.Item) + status, err := arr[6].TryInteger() + require.NoError(t, err) + require.Equal(t, int64(state.TokenStatusSuspended), status.Int64()) + }, "getToken", acc.ScriptHash().BytesBE()) + + // Non-committee cannot reinstate + invoker.InvokeFail(t, "invalid committee signature", "reinstate", acc.ScriptHash().BytesBE()) + + // Committee can reinstate + committeeInvoker.Invoke(t, true, "reinstate", acc.ScriptHash().BytesBE()) + + // Token is active again + invoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr := stack[0].Value().([]stackitem.Item) + status, err := arr[6].TryInteger() + require.NoError(t, err) + require.Equal(t, int64(state.TokenStatusActive), status.Int64()) + }, "getToken", acc.ScriptHash().BytesBE()) +} + +// Note: Full cross-contract testing of validateCaller, requireRole, requireCoreRole, and +// requirePermission would require deploying a helper contract that: +// 1. Has a PersonToken registered to its script hash +// 2. Calls the PersonToken cross-contract methods from within its own methods +// This is the intended usage pattern for these cross-contract authorization methods. diff --git a/pkg/core/native/nativeids/ids.go b/pkg/core/native/nativeids/ids.go index 1139cf0..1b0474c 100644 --- a/pkg/core/native/nativeids/ids.go +++ b/pkg/core/native/nativeids/ids.go @@ -29,4 +29,6 @@ const ( Notary int32 = -10 // Treasury is an ID of native Treasury contract. Treasury int32 = -11 + // PersonToken is an ID of native PersonToken contract. + PersonToken int32 = -12 ) diff --git a/pkg/core/native/nativenames/names.go b/pkg/core/native/nativenames/names.go index 1a9d021..99de9f2 100644 --- a/pkg/core/native/nativenames/names.go +++ b/pkg/core/native/nativenames/names.go @@ -13,6 +13,7 @@ const ( CryptoLib = "CryptoLib" StdLib = "StdLib" Treasury = "Treasury" + PersonToken = "PersonToken" ) // All contains the list of all native contract names ordered by the contract ID. @@ -28,6 +29,7 @@ var All = []string{ Oracle, Notary, Treasury, + PersonToken, } // IsValid checks if the name is a valid native contract's name. @@ -42,5 +44,6 @@ func IsValid(name string) bool { name == Notary || name == CryptoLib || name == StdLib || - name == Treasury + name == Treasury || + name == PersonToken } diff --git a/pkg/core/native/person_token.go b/pkg/core/native/person_token.go new file mode 100644 index 0000000..d7a2084 --- /dev/null +++ b/pkg/core/native/person_token.go @@ -0,0 +1,2022 @@ +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< math.MaxInt { + return errors.New("requiredApprovals out of range") + } + r.RequiredApprovals = int(ra) + + createdAt, err := items[7].TryInteger() + if err != nil { + return fmt.Errorf("invalid createdAt: %w", err) + } + r.CreatedAt = uint32(createdAt.Int64()) + + delayUntil, err := items[8].TryInteger() + if err != nil { + return fmt.Errorf("invalid delayUntil: %w", err) + } + r.DelayUntil = uint32(delayUntil.Int64()) + + expiresAt, err := items[9].TryInteger() + if err != nil { + return fmt.Errorf("invalid expiresAt: %w", err) + } + r.ExpiresAt = uint32(expiresAt.Int64()) + + status, err := items[10].TryInteger() + if err != nil { + return fmt.Errorf("invalid status: %w", err) + } + r.Status = RecoveryStatus(status.Int64()) + + return nil +}