Add VPP (Vita Presence Protocol) to native Vita contract
Implement real-time proof-of-humanity presence tracking as OAuth 2.0 extension: Native Contract Methods: - recordPresence(sessionId, deviceHash): Record heartbeat for caller's Vita - getPresence(tokenId): Get presence record with computed status - getPresenceStatus(tokenId): Get computed status (offline/online/away/invisible) - setPresenceVisibility(invisible): Toggle invisible mode - getPresenceConfig(): Get VPP configuration values State Types (pkg/core/state/vita.go): - PresenceStatus enum: Offline, Online, Away, Invisible - PresenceRecord: TokenID, LastHeartbeat, SessionID, DeviceHash, Status, Invisible - PresenceConfig: PresenceWindow (60), AwayWindow (300), MinHeartbeatInterval (15), MaxHeartbeatInterval (60) - all in blocks (~seconds) Security Features: - Session and device hash must be exactly 32 bytes - Rate limiting: Min 15 blocks between heartbeats - Invisible mode: Heartbeats recorded but status hidden - Caller must have active Vita token Internal Methods for Cross-Contract Use: - GetPresenceRecord, GetPresenceStatusInternal, IsOnline, IsPresent Events: - PresenceRecorded(tokenId, blockHeight, sessionId) - PresenceVisibilityChanged(tokenId, invisible) Tests (7 new): - GetPresenceConfig, GetPresence_NonExistent, GetPresenceStatus_NonExistent - RecordPresence_NoVita, RecordPresence_InvalidSession, RecordPresence_InvalidDevice - SetPresenceVisibility_NoVita 🤖 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
94ea427c1e
commit
c250c16e89
|
|
@ -307,3 +307,122 @@ func TestVita_SuspendReinstate(t *testing.T) {
|
||||||
// 1. Has a Vita registered to its script hash
|
// 1. Has a Vita registered to its script hash
|
||||||
// 2. Calls the Vita cross-contract methods from within its own methods
|
// 2. Calls the Vita cross-contract methods from within its own methods
|
||||||
// This is the intended usage pattern for these cross-contract authorization methods.
|
// This is the intended usage pattern for these cross-contract authorization methods.
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VPP (Vita Presence Protocol) Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// TestVita_GetPresenceConfig tests the getPresenceConfig method.
|
||||||
|
func TestVita_GetPresenceConfig(t *testing.T) {
|
||||||
|
c := newVitaClient(t)
|
||||||
|
|
||||||
|
// getPresenceConfig returns default config
|
||||||
|
c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) {
|
||||||
|
require.Equal(t, 1, len(stack))
|
||||||
|
arr := stack[0].Value().([]stackitem.Item)
|
||||||
|
require.Equal(t, 4, len(arr), "expected 4 config values")
|
||||||
|
|
||||||
|
// Verify default values
|
||||||
|
presenceWindow, err := arr[0].TryInteger()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(60), presenceWindow.Int64(), "default presenceWindow should be 60")
|
||||||
|
|
||||||
|
awayWindow, err := arr[1].TryInteger()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(300), awayWindow.Int64(), "default awayWindow should be 300")
|
||||||
|
|
||||||
|
minHeartbeat, err := arr[2].TryInteger()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(15), minHeartbeat.Int64(), "default minHeartbeatInterval should be 15")
|
||||||
|
|
||||||
|
maxHeartbeat, err := arr[3].TryInteger()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(60), maxHeartbeat.Int64(), "default maxHeartbeatInterval should be 60")
|
||||||
|
}, "getPresenceConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVita_GetPresence_NonExistent tests getPresence for non-existent token.
|
||||||
|
func TestVita_GetPresence_NonExistent(t *testing.T) {
|
||||||
|
c := newVitaClient(t)
|
||||||
|
|
||||||
|
// getPresence for non-existent tokenID should return null
|
||||||
|
c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) {
|
||||||
|
require.Equal(t, 1, len(stack))
|
||||||
|
require.Equal(t, stackitem.Null{}, stack[0], "expected null for non-existent presence")
|
||||||
|
}, "getPresence", 99999)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVita_GetPresenceStatus_NonExistent tests getPresenceStatus for non-existent token.
|
||||||
|
func TestVita_GetPresenceStatus_NonExistent(t *testing.T) {
|
||||||
|
c := newVitaClient(t)
|
||||||
|
|
||||||
|
// getPresenceStatus for non-existent tokenID should return 0 (offline)
|
||||||
|
c.Invoke(t, int64(state.PresenceStatusOffline), "getPresenceStatus", 99999)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVita_RecordPresence_NoVita tests recordPresence when caller has no Vita.
|
||||||
|
func TestVita_RecordPresence_NoVita(t *testing.T) {
|
||||||
|
c := newVitaClient(t)
|
||||||
|
e := c.Executor
|
||||||
|
|
||||||
|
acc := e.NewAccount(t)
|
||||||
|
invoker := c.WithSigners(acc)
|
||||||
|
|
||||||
|
sessionID := make([]byte, 32)
|
||||||
|
deviceHash := make([]byte, 32)
|
||||||
|
|
||||||
|
// recordPresence uses GetCallingScriptHash() - direct call fails because
|
||||||
|
// the calling script hash (transaction) doesn't have a Vita
|
||||||
|
invoker.InvokeFail(t, "caller does not have a Vita", "recordPresence", sessionID, deviceHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVita_RecordPresence_InvalidSession tests recordPresence with invalid session.
|
||||||
|
func TestVita_RecordPresence_InvalidSession(t *testing.T) {
|
||||||
|
c := newVitaClient(t)
|
||||||
|
e := c.Executor
|
||||||
|
|
||||||
|
acc := e.NewAccount(t)
|
||||||
|
invoker := c.WithSigners(acc)
|
||||||
|
|
||||||
|
// Invalid session ID (not 32 bytes)
|
||||||
|
sessionID := make([]byte, 16) // Wrong size
|
||||||
|
deviceHash := make([]byte, 32)
|
||||||
|
|
||||||
|
invoker.InvokeFail(t, "invalid session ID: must be 32 bytes", "recordPresence", sessionID, deviceHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVita_RecordPresence_InvalidDevice tests recordPresence with invalid device hash.
|
||||||
|
func TestVita_RecordPresence_InvalidDevice(t *testing.T) {
|
||||||
|
c := newVitaClient(t)
|
||||||
|
e := c.Executor
|
||||||
|
|
||||||
|
acc := e.NewAccount(t)
|
||||||
|
invoker := c.WithSigners(acc)
|
||||||
|
|
||||||
|
sessionID := make([]byte, 32)
|
||||||
|
deviceHash := make([]byte, 16) // Wrong size
|
||||||
|
|
||||||
|
invoker.InvokeFail(t, "invalid device hash: must be 32 bytes", "recordPresence", sessionID, deviceHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVita_SetPresenceVisibility_NoVita tests setPresenceVisibility when caller has no Vita.
|
||||||
|
func TestVita_SetPresenceVisibility_NoVita(t *testing.T) {
|
||||||
|
c := newVitaClient(t)
|
||||||
|
e := c.Executor
|
||||||
|
|
||||||
|
acc := e.NewAccount(t)
|
||||||
|
invoker := c.WithSigners(acc)
|
||||||
|
|
||||||
|
// setPresenceVisibility uses GetCallingScriptHash() - direct call fails
|
||||||
|
invoker.InvokeFail(t, "caller does not have a Vita", "setPresenceVisibility", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Full VPP testing of recordPresence and setPresenceVisibility with actual
|
||||||
|
// heartbeat recording would require deploying a helper contract that:
|
||||||
|
// 1. Has a Vita registered to its script hash
|
||||||
|
// 2. Calls recordPresence and setPresenceVisibility from within its own methods
|
||||||
|
// This enables testing the full VPP flow including:
|
||||||
|
// - Successful heartbeat recording
|
||||||
|
// - Rate limiting (MinHeartbeatInterval)
|
||||||
|
// - Presence status transitions (online -> away -> offline)
|
||||||
|
// - Invisible mode
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ const (
|
||||||
prefixChallenge = 0x05 // challengeID -> AuthChallenge
|
prefixChallenge = 0x05 // challengeID -> AuthChallenge
|
||||||
prefixRecovery = 0x06 // requestID -> RecoveryRequest
|
prefixRecovery = 0x06 // requestID -> RecoveryRequest
|
||||||
prefixActiveRecovery = 0x07 // tokenID + "recovery" -> requestID
|
prefixActiveRecovery = 0x07 // tokenID + "recovery" -> requestID
|
||||||
|
prefixPresence = 0x08 // tokenID -> PresenceRecord (VPP)
|
||||||
|
prefixPresenceConfig = 0x09 // -> PresenceConfig
|
||||||
prefixTokenCounter = 0x10 // -> uint64
|
prefixTokenCounter = 0x10 // -> uint64
|
||||||
prefixConfig = 0x11 // -> VitaConfig
|
prefixConfig = 0x11 // -> VitaConfig
|
||||||
)
|
)
|
||||||
|
|
@ -64,6 +66,8 @@ const (
|
||||||
RecoveryExecutedEvent = "RecoveryExecuted"
|
RecoveryExecutedEvent = "RecoveryExecuted"
|
||||||
RecoveryCancelledEvent = "RecoveryCancelled"
|
RecoveryCancelledEvent = "RecoveryCancelled"
|
||||||
VestingUpdatedEvent = "VestingUpdated"
|
VestingUpdatedEvent = "VestingUpdated"
|
||||||
|
PresenceRecordedEvent = "PresenceRecorded"
|
||||||
|
PresenceVisibilityEvent = "PresenceVisibilityChanged"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default vesting period in blocks (approximately 30 days at 1 second per block).
|
// Default vesting period in blocks (approximately 30 days at 1 second per block).
|
||||||
|
|
@ -111,6 +115,11 @@ var (
|
||||||
ErrVitaInvalidRole = errors.New("invalid core role")
|
ErrVitaInvalidRole = errors.New("invalid core role")
|
||||||
ErrVitaInvalidResource = errors.New("invalid resource")
|
ErrVitaInvalidResource = errors.New("invalid resource")
|
||||||
ErrVitaInvalidAction = errors.New("invalid action")
|
ErrVitaInvalidAction = errors.New("invalid action")
|
||||||
|
// Presence (VPP) errors
|
||||||
|
ErrPresenceRateLimited = errors.New("heartbeat rate limit exceeded")
|
||||||
|
ErrPresenceInvalidSession = errors.New("invalid session ID: must be 32 bytes")
|
||||||
|
ErrPresenceInvalidDevice = errors.New("invalid device hash: must be 32 bytes")
|
||||||
|
ErrPresenceNotFound = errors.New("no presence record found")
|
||||||
)
|
)
|
||||||
|
|
||||||
// CoreRole represents built-in roles for cross-contract access control.
|
// CoreRole represents built-in roles for cross-contract access control.
|
||||||
|
|
@ -427,6 +436,50 @@ func newVita() *Vita {
|
||||||
manifest.NewParameter("updatedBy", smartcontract.Hash160Type))
|
manifest.NewParameter("updatedBy", smartcontract.Hash160Type))
|
||||||
v.AddEvent(NewEvent(eDesc))
|
v.AddEvent(NewEvent(eDesc))
|
||||||
|
|
||||||
|
// VPP (Vita Presence Protocol) methods
|
||||||
|
|
||||||
|
// RecordPresence method - record a heartbeat for VPP
|
||||||
|
desc = NewDescriptor("recordPresence", smartcontract.BoolType,
|
||||||
|
manifest.NewParameter("sessionId", smartcontract.ByteArrayType),
|
||||||
|
manifest.NewParameter("deviceHash", smartcontract.ByteArrayType))
|
||||||
|
md = NewMethodAndPrice(v.recordPresence, 1<<15, callflag.States|callflag.AllowNotify)
|
||||||
|
v.AddMethod(md, desc)
|
||||||
|
|
||||||
|
// GetPresence method - get presence record for a token
|
||||||
|
desc = NewDescriptor("getPresence", smartcontract.ArrayType,
|
||||||
|
manifest.NewParameter("tokenId", smartcontract.IntegerType))
|
||||||
|
md = NewMethodAndPrice(v.getPresence, 1<<15, callflag.ReadStates)
|
||||||
|
v.AddMethod(md, desc)
|
||||||
|
|
||||||
|
// GetPresenceStatus method - get computed presence status
|
||||||
|
desc = NewDescriptor("getPresenceStatus", smartcontract.IntegerType,
|
||||||
|
manifest.NewParameter("tokenId", smartcontract.IntegerType))
|
||||||
|
md = NewMethodAndPrice(v.getPresenceStatus, 1<<15, callflag.ReadStates)
|
||||||
|
v.AddMethod(md, desc)
|
||||||
|
|
||||||
|
// SetPresenceVisibility method - set visibility (invisible mode)
|
||||||
|
desc = NewDescriptor("setPresenceVisibility", smartcontract.BoolType,
|
||||||
|
manifest.NewParameter("invisible", smartcontract.BoolType))
|
||||||
|
md = NewMethodAndPrice(v.setPresenceVisibility, 1<<15, callflag.States|callflag.AllowNotify)
|
||||||
|
v.AddMethod(md, desc)
|
||||||
|
|
||||||
|
// GetPresenceConfig method - get VPP configuration
|
||||||
|
desc = NewDescriptor("getPresenceConfig", smartcontract.ArrayType)
|
||||||
|
md = NewMethodAndPrice(v.getPresenceConfig, 1<<15, callflag.ReadStates)
|
||||||
|
v.AddMethod(md, desc)
|
||||||
|
|
||||||
|
// VPP Events
|
||||||
|
eDesc = NewEventDescriptor(PresenceRecordedEvent,
|
||||||
|
manifest.NewParameter("tokenId", smartcontract.IntegerType),
|
||||||
|
manifest.NewParameter("blockHeight", smartcontract.IntegerType),
|
||||||
|
manifest.NewParameter("sessionId", smartcontract.ByteArrayType))
|
||||||
|
v.AddEvent(NewEvent(eDesc))
|
||||||
|
|
||||||
|
eDesc = NewEventDescriptor(PresenceVisibilityEvent,
|
||||||
|
manifest.NewParameter("tokenId", smartcontract.IntegerType),
|
||||||
|
manifest.NewParameter("invisible", smartcontract.BoolType))
|
||||||
|
v.AddEvent(NewEvent(eDesc))
|
||||||
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2260,3 +2313,265 @@ func (v *Vita) GetVestedUntil(d *dao.Simple, tokenID uint64) uint32 {
|
||||||
}
|
}
|
||||||
return token.VestedUntil
|
return token.VestedUntil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VPP (Vita Presence Protocol) Implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// presenceKey generates the storage key for a presence record.
|
||||||
|
func (v *Vita) presenceKey(tokenID uint64) []byte {
|
||||||
|
key := make([]byte, 9)
|
||||||
|
key[0] = prefixPresence
|
||||||
|
binary.BigEndian.PutUint64(key[1:], tokenID)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// putPresence stores a presence record.
|
||||||
|
func (v *Vita) putPresence(d *dao.Simple, record *state.PresenceRecord) error {
|
||||||
|
key := v.presenceKey(record.TokenID)
|
||||||
|
item, err := record.ToStackItem()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := stackitem.Serialize(item)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.PutStorageItem(v.ID, key, data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPresenceInternal retrieves a presence record.
|
||||||
|
func (v *Vita) getPresenceInternal(d *dao.Simple, tokenID uint64) (*state.PresenceRecord, error) {
|
||||||
|
key := v.presenceKey(tokenID)
|
||||||
|
si := d.GetStorageItem(v.ID, key)
|
||||||
|
if si == nil {
|
||||||
|
return nil, ErrPresenceNotFound
|
||||||
|
}
|
||||||
|
item, err := stackitem.Deserialize(si)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
record := &state.PresenceRecord{}
|
||||||
|
if err := record.FromStackItem(item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPresenceConfigInternal retrieves or creates default presence config.
|
||||||
|
func (v *Vita) getPresenceConfigInternal(d *dao.Simple) state.PresenceConfig {
|
||||||
|
key := []byte{prefixPresenceConfig}
|
||||||
|
si := d.GetStorageItem(v.ID, key)
|
||||||
|
if si == nil {
|
||||||
|
return state.DefaultPresenceConfig()
|
||||||
|
}
|
||||||
|
item, err := stackitem.Deserialize(si)
|
||||||
|
if err != nil {
|
||||||
|
return state.DefaultPresenceConfig()
|
||||||
|
}
|
||||||
|
config := state.PresenceConfig{}
|
||||||
|
if err := config.FromStackItem(item); err != nil {
|
||||||
|
return state.DefaultPresenceConfig()
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// computePresenceStatus computes the current presence status based on last heartbeat.
|
||||||
|
func (v *Vita) computePresenceStatus(d *dao.Simple, tokenID uint64, currentBlock uint32) state.PresenceStatus {
|
||||||
|
record, err := v.getPresenceInternal(d, tokenID)
|
||||||
|
if err != nil {
|
||||||
|
return state.PresenceStatusOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
// If invisible mode, return invisible
|
||||||
|
if record.Invisible {
|
||||||
|
return state.PresenceStatusInvisible
|
||||||
|
}
|
||||||
|
|
||||||
|
config := v.getPresenceConfigInternal(d)
|
||||||
|
blocksSinceHeartbeat := currentBlock - record.LastHeartbeat
|
||||||
|
|
||||||
|
if blocksSinceHeartbeat <= config.PresenceWindow {
|
||||||
|
return state.PresenceStatusOnline
|
||||||
|
} else if blocksSinceHeartbeat <= config.AwayWindow {
|
||||||
|
return state.PresenceStatusAway
|
||||||
|
}
|
||||||
|
return state.PresenceStatusOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPresence records a heartbeat for the caller's Vita.
|
||||||
|
// This is the core VPP method called by the Symbey app.
|
||||||
|
func (v *Vita) recordPresence(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
||||||
|
sessionID, err := args[0].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("invalid sessionId: %w", err))
|
||||||
|
}
|
||||||
|
if len(sessionID) != 32 {
|
||||||
|
panic(ErrPresenceInvalidSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceHash, err := args[1].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("invalid deviceHash: %w", err))
|
||||||
|
}
|
||||||
|
if len(deviceHash) != 32 {
|
||||||
|
panic(ErrPresenceInvalidDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get caller's Vita
|
||||||
|
caller := ic.VM.GetCallingScriptHash()
|
||||||
|
token, err := v.getTokenByOwnerInternal(ic.DAO, caller)
|
||||||
|
if err != nil || token == nil {
|
||||||
|
panic(ErrCallerHasNoVita)
|
||||||
|
}
|
||||||
|
if token.Status != state.TokenStatusActive {
|
||||||
|
panic(ErrTokenNotActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limiting
|
||||||
|
config := v.getPresenceConfigInternal(ic.DAO)
|
||||||
|
existing, _ := v.getPresenceInternal(ic.DAO, token.TokenID)
|
||||||
|
if existing != nil {
|
||||||
|
blocksSinceLastHeartbeat := ic.Block.Index - existing.LastHeartbeat
|
||||||
|
if blocksSinceLastHeartbeat < config.MinHeartbeatInterval {
|
||||||
|
panic(ErrPresenceRateLimited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update presence record
|
||||||
|
record := &state.PresenceRecord{
|
||||||
|
TokenID: token.TokenID,
|
||||||
|
LastHeartbeat: ic.Block.Index,
|
||||||
|
SessionID: sessionID,
|
||||||
|
DeviceHash: deviceHash,
|
||||||
|
Status: state.PresenceStatusOnline,
|
||||||
|
Invisible: existing != nil && existing.Invisible,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.putPresence(ic.DAO, record); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
ic.AddNotification(v.Hash, PresenceRecordedEvent, stackitem.NewArray([]stackitem.Item{
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(ic.Block.Index))),
|
||||||
|
stackitem.NewByteArray(sessionID),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return stackitem.NewBool(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPresence retrieves the presence record for a token.
|
||||||
|
func (v *Vita) getPresence(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
||||||
|
tokenID, err := args[0].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("invalid tokenId: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := v.getPresenceInternal(ic.DAO, tokenID.Uint64())
|
||||||
|
if err != nil {
|
||||||
|
return stackitem.Null{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute current status based on time since last heartbeat
|
||||||
|
record.Status = v.computePresenceStatus(ic.DAO, record.TokenID, ic.Block.Index)
|
||||||
|
|
||||||
|
item, err := record.ToStackItem()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPresenceStatus returns the computed presence status for a token.
|
||||||
|
func (v *Vita) getPresenceStatus(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
||||||
|
tokenID, err := args[0].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("invalid tokenId: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
status := v.computePresenceStatus(ic.DAO, tokenID.Uint64(), ic.Block.Index)
|
||||||
|
return stackitem.NewBigInteger(big.NewInt(int64(status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPresenceVisibility sets the caller's presence visibility (invisible mode).
|
||||||
|
func (v *Vita) setPresenceVisibility(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
||||||
|
invisible, err := args[0].TryBool()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("invalid invisible: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get caller's Vita
|
||||||
|
caller := ic.VM.GetCallingScriptHash()
|
||||||
|
token, err := v.getTokenByOwnerInternal(ic.DAO, caller)
|
||||||
|
if err != nil || token == nil {
|
||||||
|
panic(ErrCallerHasNoVita)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create presence record
|
||||||
|
record, _ := v.getPresenceInternal(ic.DAO, token.TokenID)
|
||||||
|
if record == nil {
|
||||||
|
record = &state.PresenceRecord{
|
||||||
|
TokenID: token.TokenID,
|
||||||
|
LastHeartbeat: 0,
|
||||||
|
SessionID: make([]byte, 32),
|
||||||
|
DeviceHash: make([]byte, 32),
|
||||||
|
Status: state.PresenceStatusOffline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Invisible = invisible
|
||||||
|
if invisible {
|
||||||
|
record.Status = state.PresenceStatusInvisible
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.putPresence(ic.DAO, record); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
ic.AddNotification(v.Hash, PresenceVisibilityEvent, stackitem.NewArray([]stackitem.Item{
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))),
|
||||||
|
stackitem.NewBool(invisible),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return stackitem.NewBool(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPresenceConfig returns the VPP configuration.
|
||||||
|
func (v *Vita) getPresenceConfig(ic *interop.Context, _ []stackitem.Item) stackitem.Item {
|
||||||
|
config := v.getPresenceConfigInternal(ic.DAO)
|
||||||
|
item, err := config.ToStackItem()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VPP Public Internal Methods (for cross-contract access)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetPresenceRecord returns the presence record for a token (internal use).
|
||||||
|
func (v *Vita) GetPresenceRecord(d *dao.Simple, tokenID uint64) (*state.PresenceRecord, error) {
|
||||||
|
return v.getPresenceInternal(d, tokenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPresenceStatusInternal returns the computed presence status (internal use).
|
||||||
|
func (v *Vita) GetPresenceStatusInternal(d *dao.Simple, tokenID uint64, currentBlock uint32) state.PresenceStatus {
|
||||||
|
return v.computePresenceStatus(d, tokenID, currentBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOnline checks if a Vita holder is currently online.
|
||||||
|
func (v *Vita) IsOnline(d *dao.Simple, tokenID uint64, currentBlock uint32) bool {
|
||||||
|
status := v.computePresenceStatus(d, tokenID, currentBlock)
|
||||||
|
return status == state.PresenceStatusOnline
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPresent checks if a Vita holder is present (online or away, not offline).
|
||||||
|
func (v *Vita) IsPresent(d *dao.Simple, tokenID uint64, currentBlock uint32) bool {
|
||||||
|
status := v.computePresenceStatus(d, tokenID, currentBlock)
|
||||||
|
return status == state.PresenceStatusOnline || status == state.PresenceStatusAway
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,20 @@ const (
|
||||||
RecoveryStatusExpired RecoveryStatus = 4
|
RecoveryStatusExpired RecoveryStatus = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PresenceStatus represents the online presence status of a Vita holder.
|
||||||
|
type PresenceStatus uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PresenceStatusOffline indicates no recent heartbeat.
|
||||||
|
PresenceStatusOffline PresenceStatus = 0
|
||||||
|
// PresenceStatusOnline indicates active heartbeat within presence window.
|
||||||
|
PresenceStatusOnline PresenceStatus = 1
|
||||||
|
// PresenceStatusAway indicates heartbeat within extended window but outside primary window.
|
||||||
|
PresenceStatusAway PresenceStatus = 2
|
||||||
|
// PresenceStatusInvisible indicates heartbeats are sent but status is hidden.
|
||||||
|
PresenceStatusInvisible PresenceStatus = 3
|
||||||
|
)
|
||||||
|
|
||||||
// Vita represents a soul-bound identity token.
|
// Vita represents a soul-bound identity token.
|
||||||
type Vita struct {
|
type Vita struct {
|
||||||
TokenID uint64 // Unique sequential identifier
|
TokenID uint64 // Unique sequential identifier
|
||||||
|
|
@ -478,3 +492,148 @@ func (r *RecoveryRequest) FromStackItem(item stackitem.Item) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PresenceRecord represents a VPP (Vita Presence Protocol) heartbeat record.
|
||||||
|
// Used for real-time proof of humanity in online applications.
|
||||||
|
type PresenceRecord struct {
|
||||||
|
TokenID uint64 // Associated Vita token
|
||||||
|
LastHeartbeat uint32 // Block height of last heartbeat
|
||||||
|
SessionID []byte // Current session identifier (32 bytes)
|
||||||
|
DeviceHash []byte // Hash of device fingerprint (privacy)
|
||||||
|
Status PresenceStatus // Current visibility status
|
||||||
|
Invisible bool // If true, heartbeats are recorded but status is hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStackItem implements stackitem.Convertible interface.
|
||||||
|
func (p *PresenceRecord) ToStackItem() (stackitem.Item, error) {
|
||||||
|
return stackitem.NewStruct([]stackitem.Item{
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(p.TokenID))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(p.LastHeartbeat))),
|
||||||
|
stackitem.NewByteArray(p.SessionID),
|
||||||
|
stackitem.NewByteArray(p.DeviceHash),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(p.Status))),
|
||||||
|
stackitem.NewBool(p.Invisible),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromStackItem implements stackitem.Convertible interface.
|
||||||
|
func (p *PresenceRecord) FromStackItem(item stackitem.Item) error {
|
||||||
|
items, ok := item.Value().([]stackitem.Item)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not a struct")
|
||||||
|
}
|
||||||
|
if len(items) != 6 {
|
||||||
|
return fmt.Errorf("wrong number of elements: expected 6, got %d", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenID, err := items[0].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid tokenID: %w", err)
|
||||||
|
}
|
||||||
|
p.TokenID = tokenID.Uint64()
|
||||||
|
|
||||||
|
lastHeartbeat, err := items[1].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid lastHeartbeat: %w", err)
|
||||||
|
}
|
||||||
|
p.LastHeartbeat = uint32(lastHeartbeat.Int64())
|
||||||
|
|
||||||
|
p.SessionID, err = items[2].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid sessionID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.DeviceHash, err = items[3].TryBytes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid deviceHash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := items[4].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid status: %w", err)
|
||||||
|
}
|
||||||
|
p.Status = PresenceStatus(status.Int64())
|
||||||
|
|
||||||
|
p.Invisible, err = items[5].TryBool()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid invisible: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresenceConfig holds configuration for the VPP presence system.
|
||||||
|
type PresenceConfig struct {
|
||||||
|
// PresenceWindow is the number of blocks within which a heartbeat is considered "online".
|
||||||
|
// Default: 60 blocks (~1 minute at 1 second/block)
|
||||||
|
PresenceWindow uint32
|
||||||
|
|
||||||
|
// AwayWindow is the extended window for "away" status.
|
||||||
|
// Default: 300 blocks (~5 minutes)
|
||||||
|
AwayWindow uint32
|
||||||
|
|
||||||
|
// MinHeartbeatInterval is the minimum blocks between heartbeats (rate limiting).
|
||||||
|
// Default: 15 blocks (~15 seconds)
|
||||||
|
MinHeartbeatInterval uint32
|
||||||
|
|
||||||
|
// MaxHeartbeatInterval is the maximum blocks before a heartbeat is required.
|
||||||
|
// Default: 60 blocks (~1 minute)
|
||||||
|
MaxHeartbeatInterval uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStackItem implements stackitem.Convertible interface.
|
||||||
|
func (c *PresenceConfig) ToStackItem() (stackitem.Item, error) {
|
||||||
|
return stackitem.NewStruct([]stackitem.Item{
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(c.PresenceWindow))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(c.AwayWindow))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(c.MinHeartbeatInterval))),
|
||||||
|
stackitem.NewBigInteger(big.NewInt(int64(c.MaxHeartbeatInterval))),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromStackItem implements stackitem.Convertible interface.
|
||||||
|
func (c *PresenceConfig) FromStackItem(item stackitem.Item) error {
|
||||||
|
items, ok := item.Value().([]stackitem.Item)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not a struct")
|
||||||
|
}
|
||||||
|
if len(items) != 4 {
|
||||||
|
return fmt.Errorf("wrong number of elements: expected 4, got %d", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
presenceWindow, err := items[0].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid presenceWindow: %w", err)
|
||||||
|
}
|
||||||
|
c.PresenceWindow = uint32(presenceWindow.Int64())
|
||||||
|
|
||||||
|
awayWindow, err := items[1].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid awayWindow: %w", err)
|
||||||
|
}
|
||||||
|
c.AwayWindow = uint32(awayWindow.Int64())
|
||||||
|
|
||||||
|
minHeartbeat, err := items[2].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid minHeartbeatInterval: %w", err)
|
||||||
|
}
|
||||||
|
c.MinHeartbeatInterval = uint32(minHeartbeat.Int64())
|
||||||
|
|
||||||
|
maxHeartbeat, err := items[3].TryInteger()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid maxHeartbeatInterval: %w", err)
|
||||||
|
}
|
||||||
|
c.MaxHeartbeatInterval = uint32(maxHeartbeat.Int64())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPresenceConfig returns the default VPP configuration.
|
||||||
|
func DefaultPresenceConfig() PresenceConfig {
|
||||||
|
return PresenceConfig{
|
||||||
|
PresenceWindow: 60, // ~1 minute
|
||||||
|
AwayWindow: 300, // ~5 minutes
|
||||||
|
MinHeartbeatInterval: 15, // ~15 seconds
|
||||||
|
MaxHeartbeatInterval: 60, // ~1 minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue