Add PersonToken native contract for soul-bound identity tokens
Implements PersonToken as a native contract following NeoGo patterns: Core Token Lifecycle (Phase 1): - register: Create new PersonToken with owner, personHash, isEntity, recoveryHash - getToken/getTokenByID: Retrieve token by owner address or sequential ID - exists/totalSupply: Check token existence and get total count - suspend/reinstate: Committee-controlled token status management Attribute Management (Phase 2): - setAttribute/getAttribute: Manage identity attributes with attestation - revokeAttribute/verifyAttribute: Attribute lifecycle management Passwordless Authentication (Phase 3): - createChallenge/fulfillChallenge/verifyAuth: Challenge-response auth flow Key Recovery (Phase 4): - initiateRecovery/approveRecovery/executeRecovery/cancelRecovery - Multi-approval recovery with configurable delay Cross-Contract Integration (Phase 5): - validateCaller: Verify calling contract has valid PersonToken - requireRole: Check caller has specific role assignment - requireCoreRole: Check caller has core role (User/Verified/Committee/Attestor/Recovery) - requirePermission: Check caller has resource/action/scope permission Files added: - pkg/core/native/person_token.go: Main contract implementation - pkg/core/state/person_token.go: State structs with serialization - pkg/core/native/native_test/person_token_test.go: Test coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cf481e4137
commit
99ba041a85
|
|
@ -110,6 +110,16 @@ type (
|
||||||
ExpirationOf(dao *dao.Simple, acc util.Uint160) uint32
|
ExpirationOf(dao *dao.Simple, acc util.Uint160) uint32
|
||||||
GetMaxNotValidBeforeDelta(dao *dao.Simple) 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
|
// Contracts is a convenient wrapper around an arbitrary set of native contracts
|
||||||
|
|
@ -225,6 +235,12 @@ func (cs *Contracts) Notary() INotary {
|
||||||
return nil
|
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.
|
// NewDefaultContracts returns a new set of default native contracts.
|
||||||
func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract {
|
func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract {
|
||||||
mgmt := NewManagement()
|
mgmt := NewManagement()
|
||||||
|
|
@ -261,6 +277,9 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract {
|
||||||
treasury := newTreasury()
|
treasury := newTreasury()
|
||||||
treasury.NEO = neo
|
treasury.NEO = neo
|
||||||
|
|
||||||
|
personToken := newPersonToken()
|
||||||
|
personToken.NEO = neo
|
||||||
|
|
||||||
return []interop.Contract{
|
return []interop.Contract{
|
||||||
mgmt,
|
mgmt,
|
||||||
s,
|
s,
|
||||||
|
|
@ -273,5 +292,6 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract {
|
||||||
oracle,
|
oracle,
|
||||||
notary,
|
notary,
|
||||||
treasury,
|
treasury,
|
||||||
|
personToken,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||||
|
|
@ -29,4 +29,6 @@ const (
|
||||||
Notary int32 = -10
|
Notary int32 = -10
|
||||||
// Treasury is an ID of native Treasury contract.
|
// Treasury is an ID of native Treasury contract.
|
||||||
Treasury int32 = -11
|
Treasury int32 = -11
|
||||||
|
// PersonToken is an ID of native PersonToken contract.
|
||||||
|
PersonToken int32 = -12
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const (
|
||||||
CryptoLib = "CryptoLib"
|
CryptoLib = "CryptoLib"
|
||||||
StdLib = "StdLib"
|
StdLib = "StdLib"
|
||||||
Treasury = "Treasury"
|
Treasury = "Treasury"
|
||||||
|
PersonToken = "PersonToken"
|
||||||
)
|
)
|
||||||
|
|
||||||
// All contains the list of all native contract names ordered by the contract ID.
|
// All contains the list of all native contract names ordered by the contract ID.
|
||||||
|
|
@ -28,6 +29,7 @@ var All = []string{
|
||||||
Oracle,
|
Oracle,
|
||||||
Notary,
|
Notary,
|
||||||
Treasury,
|
Treasury,
|
||||||
|
PersonToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid checks if the name is a valid native contract's name.
|
// IsValid checks if the name is a valid native contract's name.
|
||||||
|
|
@ -42,5 +44,6 @@ func IsValid(name string) bool {
|
||||||
name == Notary ||
|
name == Notary ||
|
||||||
name == CryptoLib ||
|
name == CryptoLib ||
|
||||||
name == StdLib ||
|
name == StdLib ||
|
||||||
name == Treasury
|
name == Treasury ||
|
||||||
|
name == PersonToken
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,472 @@
|
||||||
|
package state
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/big"
|
||||||
|
|
||||||
|
"github.com/tutus-one/tutus-chain/pkg/util"
|
||||||
|
"github.com/tutus-one/tutus-chain/pkg/vm/stackitem"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenStatus represents the status of a PersonToken.
|
||||||
|
type TokenStatus uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenStatusActive indicates an active token.
|
||||||
|
TokenStatusActive TokenStatus = 0
|
||||||
|
// TokenStatusSuspended indicates a temporarily suspended token.
|
||||||
|
TokenStatusSuspended TokenStatus = 1
|
||||||
|
// TokenStatusRevoked indicates a permanently revoked token.
|
||||||
|
TokenStatusRevoked TokenStatus = 2
|
||||||
|
// TokenStatusRecovering indicates recovery is in progress.
|
||||||
|
TokenStatusRecovering TokenStatus = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// DisclosureLevel represents the visibility level of an attribute.
|
||||||
|
type DisclosureLevel uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DisclosurePrivate means only the owner can see the attribute.
|
||||||
|
DisclosurePrivate DisclosureLevel = 0
|
||||||
|
// DisclosureVerifier means owner and designated verifiers can see it.
|
||||||
|
DisclosureVerifier DisclosureLevel = 1
|
||||||
|
// DisclosureRole means owner and callers with specified roles can see it.
|
||||||
|
DisclosureRole DisclosureLevel = 2
|
||||||
|
// DisclosurePublic means anyone can see the attribute.
|
||||||
|
DisclosurePublic DisclosureLevel = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecoveryStatus represents the status of a recovery request.
|
||||||
|
type RecoveryStatus uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RecoveryStatusPending indicates recovery is pending approval.
|
||||||
|
RecoveryStatusPending RecoveryStatus = 0
|
||||||
|
// RecoveryStatusApproved indicates recovery has been approved.
|
||||||
|
RecoveryStatusApproved RecoveryStatus = 1
|
||||||
|
// RecoveryStatusExecuted indicates recovery has been executed.
|
||||||
|
RecoveryStatusExecuted RecoveryStatus = 2
|
||||||
|
// RecoveryStatusDenied indicates recovery has been denied.
|
||||||
|
RecoveryStatusDenied RecoveryStatus = 3
|
||||||
|
// RecoveryStatusExpired indicates recovery request has expired.
|
||||||
|
RecoveryStatusExpired RecoveryStatus = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// PersonToken represents a soul-bound identity token.
|
||||||
|
type PersonToken struct {
|
||||||
|
TokenID uint64 // Unique sequential identifier
|
||||||
|
Owner util.Uint160 // Owner's script hash
|
||||||
|
PersonHash []byte // Hash of biometric/identity proof
|
||||||
|
IsEntity bool // True if organization, false if natural person
|
||||||
|
CreatedAt uint32 // Block height when created
|
||||||
|
UpdatedAt uint32 // Block height of last modification
|
||||||
|
Status TokenStatus // Current status
|
||||||
|
StatusReason string // Reason for status change
|
||||||
|
RecoveryHash []byte // Hash of recovery mechanism
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStackItem implements stackitem.Convertible interface.
|
||||||
|
func (t *PersonToken) ToStackItem() (stackitem.Item, error) {
|
||||||
|
return stackitem.NewStruct([]stackitem.Item{
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(t.TokenID))),
|
||||||
|
stackitem.NewByteArray(t.Owner.BytesBE()),
|
||||||
|
stackitem.NewByteArray(t.PersonHash),
|
||||||
|
stackitem.NewBool(t.IsEntity),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(t.CreatedAt))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(t.UpdatedAt))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(t.Status))),
|
||||||
|
stackitem.NewByteArray([]byte(t.StatusReason)),
|
||||||
|
stackitem.NewByteArray(t.RecoveryHash),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromStackItem implements stackitem.Convertible interface.
|
||||||
|
func (t *PersonToken) FromStackItem(item stackitem.Item) error {
|
||||||
|
items, ok := item.Value().([]stackitem.Item)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not a struct")
|
||||||
|
}
|
||||||
|
if len(items) != 9 {
|
||||||
|
return fmt.Errorf("wrong number of elements: expected 9, got %d", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenID, err := items[0].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid tokenID: %w", err)
|
||||||
|
}
|
||||||
|
t.TokenID = tokenID.Uint64()
|
||||||
|
|
||||||
|
ownerBytes, err := items[1].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid owner: %w", err)
|
||||||
|
}
|
||||||
|
t.Owner, err = util.Uint160DecodeBytesBE(ownerBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid owner hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.PersonHash, err = items[2].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid personHash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isEntity, err := items[3].TryBool()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid isEntity: %w", err)
|
||||||
|
}
|
||||||
|
t.IsEntity = isEntity
|
||||||
|
|
||||||
|
createdAt, err := items[4].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid createdAt: %w", err)
|
||||||
|
}
|
||||||
|
t.CreatedAt = uint32(createdAt.Int64())
|
||||||
|
|
||||||
|
updatedAt, err := items[5].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid updatedAt: %w", err)
|
||||||
|
}
|
||||||
|
t.UpdatedAt = uint32(updatedAt.Int64())
|
||||||
|
|
||||||
|
status, err := items[6].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid status: %w", err)
|
||||||
|
}
|
||||||
|
t.Status = TokenStatus(status.Int64())
|
||||||
|
|
||||||
|
statusReasonBytes, err := items[7].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid statusReason: %w", err)
|
||||||
|
}
|
||||||
|
t.StatusReason = string(statusReasonBytes)
|
||||||
|
|
||||||
|
t.RecoveryHash, err = items[8].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid recoveryHash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribute represents an identity attribute with disclosure control.
|
||||||
|
type Attribute struct {
|
||||||
|
Key string // Attribute name
|
||||||
|
ValueHash []byte // Hash of value (privacy)
|
||||||
|
ValueEnc []byte // Encrypted value (optional)
|
||||||
|
Attestor util.Uint160 // Who attested this (script hash)
|
||||||
|
AttestedAt uint32 // Block height when attested
|
||||||
|
ExpiresAt uint32 // Optional expiration (0 = never)
|
||||||
|
Revoked bool // Whether attribute has been revoked
|
||||||
|
RevokedAt uint32 // Block height when revoked
|
||||||
|
DisclosureLevel DisclosureLevel // Visibility level
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStackItem implements stackitem.Convertible interface.
|
||||||
|
func (a *Attribute) ToStackItem() (stackitem.Item, error) {
|
||||||
|
return stackitem.NewStruct([]stackitem.Item{
|
||||||
|
stackitem.NewByteArray([]byte(a.Key)),
|
||||||
|
stackitem.NewByteArray(a.ValueHash),
|
||||||
|
stackitem.NewByteArray(a.ValueEnc),
|
||||||
|
stackitem.NewByteArray(a.Attestor.BytesBE()),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(a.AttestedAt))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(a.ExpiresAt))),
|
||||||
|
stackitem.NewBool(a.Revoked),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(a.RevokedAt))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(a.DisclosureLevel))),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromStackItem implements stackitem.Convertible interface.
|
||||||
|
func (a *Attribute) FromStackItem(item stackitem.Item) error {
|
||||||
|
items, ok := item.Value().([]stackitem.Item)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not a struct")
|
||||||
|
}
|
||||||
|
if len(items) != 9 {
|
||||||
|
return fmt.Errorf("wrong number of elements: expected 9, got %d", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := items[0].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid key: %w", err)
|
||||||
|
}
|
||||||
|
a.Key = string(keyBytes)
|
||||||
|
|
||||||
|
a.ValueHash, err = items[1].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid valueHash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ValueEnc, err = items[2].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid valueEnc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attestorBytes, err := items[3].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid attestor: %w", err)
|
||||||
|
}
|
||||||
|
a.Attestor, err = util.Uint160DecodeBytesBE(attestorBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid attestor hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attestedAt, err := items[4].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid attestedAt: %w", err)
|
||||||
|
}
|
||||||
|
a.AttestedAt = uint32(attestedAt.Int64())
|
||||||
|
|
||||||
|
expiresAt, err := items[5].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid expiresAt: %w", err)
|
||||||
|
}
|
||||||
|
a.ExpiresAt = uint32(expiresAt.Int64())
|
||||||
|
|
||||||
|
a.Revoked, err = items[6].TryBool()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid revoked: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revokedAt, err := items[7].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid revokedAt: %w", err)
|
||||||
|
}
|
||||||
|
a.RevokedAt = uint32(revokedAt.Int64())
|
||||||
|
|
||||||
|
disclosureLevel, err := items[8].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid disclosureLevel: %w", err)
|
||||||
|
}
|
||||||
|
a.DisclosureLevel = DisclosureLevel(disclosureLevel.Int64())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthChallenge represents a passwordless authentication challenge.
|
||||||
|
type AuthChallenge struct {
|
||||||
|
ChallengeID util.Uint256 // Unique challenge identifier
|
||||||
|
TokenID uint64 // Associated PersonToken
|
||||||
|
Nonce []byte // Random bytes to sign
|
||||||
|
CreatedAt uint32 // Block height when created
|
||||||
|
ExpiresAt uint32 // Block height when expires
|
||||||
|
Purpose string // "login", "sign", "recover"
|
||||||
|
Fulfilled bool // Whether challenge has been fulfilled
|
||||||
|
FulfilledAt uint32 // Block height when fulfilled
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStackItem implements stackitem.Convertible interface.
|
||||||
|
func (c *AuthChallenge) ToStackItem() (stackitem.Item, error) {
|
||||||
|
return stackitem.NewStruct([]stackitem.Item{
|
||||||
|
stackitem.NewByteArray(c.ChallengeID.BytesBE()),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(c.TokenID))),
|
||||||
|
stackitem.NewByteArray(c.Nonce),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(c.CreatedAt))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(c.ExpiresAt))),
|
||||||
|
stackitem.NewByteArray([]byte(c.Purpose)),
|
||||||
|
stackitem.NewBool(c.Fulfilled),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(c.FulfilledAt))),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromStackItem implements stackitem.Convertible interface.
|
||||||
|
func (c *AuthChallenge) FromStackItem(item stackitem.Item) error {
|
||||||
|
items, ok := item.Value().([]stackitem.Item)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not a struct")
|
||||||
|
}
|
||||||
|
if len(items) != 8 {
|
||||||
|
return fmt.Errorf("wrong number of elements: expected 8, got %d", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
challengeIDBytes, err := items[0].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid challengeID: %w", err)
|
||||||
|
}
|
||||||
|
c.ChallengeID, err = util.Uint256DecodeBytesBE(challengeIDBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid challengeID hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenID, err := items[1].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid tokenID: %w", err)
|
||||||
|
}
|
||||||
|
c.TokenID = tokenID.Uint64()
|
||||||
|
|
||||||
|
c.Nonce, err = items[2].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt, err := items[3].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid createdAt: %w", err)
|
||||||
|
}
|
||||||
|
c.CreatedAt = uint32(createdAt.Int64())
|
||||||
|
|
||||||
|
expiresAt, err := items[4].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid expiresAt: %w", err)
|
||||||
|
}
|
||||||
|
c.ExpiresAt = uint32(expiresAt.Int64())
|
||||||
|
|
||||||
|
purposeBytes, err := items[5].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid purpose: %w", err)
|
||||||
|
}
|
||||||
|
c.Purpose = string(purposeBytes)
|
||||||
|
|
||||||
|
c.Fulfilled, err = items[6].TryBool()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid fulfilled: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fulfilledAt, err := items[7].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid fulfilledAt: %w", err)
|
||||||
|
}
|
||||||
|
c.FulfilledAt = uint32(fulfilledAt.Int64())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryRequest represents a key recovery request.
|
||||||
|
type RecoveryRequest struct {
|
||||||
|
RequestID util.Uint256 // Unique request identifier
|
||||||
|
TokenID uint64 // Token being recovered
|
||||||
|
NewOwner util.Uint160 // Proposed new owner
|
||||||
|
Requester util.Uint160 // Who initiated recovery
|
||||||
|
Evidence []byte // Encrypted evidence hash
|
||||||
|
Approvals []util.Uint160 // Approvers who have approved
|
||||||
|
RequiredApprovals int // Required number of approvals
|
||||||
|
CreatedAt uint32 // Block height when created
|
||||||
|
DelayUntil uint32 // Block height when executable
|
||||||
|
ExpiresAt uint32 // Block height when expires
|
||||||
|
Status RecoveryStatus // Current status
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStackItem implements stackitem.Convertible interface.
|
||||||
|
func (r *RecoveryRequest) ToStackItem() (stackitem.Item, error) {
|
||||||
|
approvals := make([]stackitem.Item, len(r.Approvals))
|
||||||
|
for i, a := range r.Approvals {
|
||||||
|
approvals[i] = stackitem.NewByteArray(a.BytesBE())
|
||||||
|
}
|
||||||
|
|
||||||
|
return stackitem.NewStruct([]stackitem.Item{
|
||||||
|
stackitem.NewByteArray(r.RequestID.BytesBE()),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(r.TokenID))),
|
||||||
|
stackitem.NewByteArray(r.NewOwner.BytesBE()),
|
||||||
|
stackitem.NewByteArray(r.Requester.BytesBE()),
|
||||||
|
stackitem.NewByteArray(r.Evidence),
|
||||||
|
stackitem.NewArray(approvals),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(r.RequiredApprovals))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(r.CreatedAt))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(r.DelayUntil))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(r.ExpiresAt))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(r.Status))),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromStackItem implements stackitem.Convertible interface.
|
||||||
|
func (r *RecoveryRequest) FromStackItem(item stackitem.Item) error {
|
||||||
|
items, ok := item.Value().([]stackitem.Item)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not a struct")
|
||||||
|
}
|
||||||
|
if len(items) != 11 {
|
||||||
|
return fmt.Errorf("wrong number of elements: expected 11, got %d", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
requestIDBytes, err := items[0].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid requestID: %w", err)
|
||||||
|
}
|
||||||
|
r.RequestID, err = util.Uint256DecodeBytesBE(requestIDBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid requestID hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenID, err := items[1].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid tokenID: %w", err)
|
||||||
|
}
|
||||||
|
r.TokenID = tokenID.Uint64()
|
||||||
|
|
||||||
|
newOwnerBytes, err := items[2].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid newOwner: %w", err)
|
||||||
|
}
|
||||||
|
r.NewOwner, err = util.Uint160DecodeBytesBE(newOwnerBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid newOwner hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requesterBytes, err := items[3].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid requester: %w", err)
|
||||||
|
}
|
||||||
|
r.Requester, err = util.Uint160DecodeBytesBE(requesterBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid requester hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Evidence, err = items[4].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid evidence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalsArray, ok := items[5].Value().([]stackitem.Item)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("approvals is not an array")
|
||||||
|
}
|
||||||
|
r.Approvals = make([]util.Uint160, len(approvalsArray))
|
||||||
|
for i, a := range approvalsArray {
|
||||||
|
approvalBytes, err := a.TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid approval %d: %w", i, err)
|
||||||
|
}
|
||||||
|
r.Approvals[i], err = util.Uint160DecodeBytesBE(approvalBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid approval %d hash: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredApprovals, err := items[6].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid requiredApprovals: %w", err)
|
||||||
|
}
|
||||||
|
ra := requiredApprovals.Int64()
|
||||||
|
if ra < 0 || ra > 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue