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:
Tutus Development 2025-12-27 17:01:59 +00:00
parent 94ea427c1e
commit c250c16e89
3 changed files with 615 additions and 22 deletions

View File

@ -307,3 +307,122 @@ func TestVita_SuspendReinstate(t *testing.T) {
// 1. Has a Vita registered to its script hash
// 2. Calls the Vita cross-contract methods from within its own 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

View File

@ -38,32 +38,36 @@ type VitaCache struct {
// Storage key prefixes for Vita.
const (
prefixTokenByOwner = 0x01 // owner (Uint160) -> tokenID (uint64)
prefixTokenByID = 0x02 // tokenID (uint64) -> Vita token
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 // -> VitaConfig
prefixTokenByOwner = 0x01 // owner (Uint160) -> tokenID (uint64)
prefixTokenByID = 0x02 // tokenID (uint64) -> Vita token
prefixPersonHash = 0x03 // personHash -> tokenID (uniqueness)
prefixAttribute = 0x04 // tokenID + attrKey -> Attribute
prefixChallenge = 0x05 // challengeID -> AuthChallenge
prefixRecovery = 0x06 // requestID -> RecoveryRequest
prefixActiveRecovery = 0x07 // tokenID + "recovery" -> requestID
prefixPresence = 0x08 // tokenID -> PresenceRecord (VPP)
prefixPresenceConfig = 0x09 // -> PresenceConfig
prefixTokenCounter = 0x10 // -> uint64
prefixConfig = 0x11 // -> VitaConfig
)
// Event names for Vita.
const (
VitaCreatedEvent = "VitaCreated"
VitaSuspendedEvent = "VitaSuspended"
VitaReinstatedEvent = "VitaReinstated"
VitaRevokedEvent = "VitaRevoked"
AttributeSetEvent = "AttributeSet"
AttributeRevokedEvent = "AttributeRevoked"
AuthenticationSuccessEvent = "AuthenticationSuccess"
AuthChallengeCreatedEvent = "AuthChallengeCreated"
RecoveryInitiatedEvent = "RecoveryInitiated"
RecoveryApprovalEvent = "RecoveryApproval"
RecoveryExecutedEvent = "RecoveryExecuted"
RecoveryCancelledEvent = "RecoveryCancelled"
VestingUpdatedEvent = "VestingUpdated"
VitaCreatedEvent = "VitaCreated"
VitaSuspendedEvent = "VitaSuspended"
VitaReinstatedEvent = "VitaReinstated"
VitaRevokedEvent = "VitaRevoked"
AttributeSetEvent = "AttributeSet"
AttributeRevokedEvent = "AttributeRevoked"
AuthenticationSuccessEvent = "AuthenticationSuccess"
AuthChallengeCreatedEvent = "AuthChallengeCreated"
RecoveryInitiatedEvent = "RecoveryInitiated"
RecoveryApprovalEvent = "RecoveryApproval"
RecoveryExecutedEvent = "RecoveryExecuted"
RecoveryCancelledEvent = "RecoveryCancelled"
VestingUpdatedEvent = "VestingUpdated"
PresenceRecordedEvent = "PresenceRecorded"
PresenceVisibilityEvent = "PresenceVisibilityChanged"
)
// Default vesting period in blocks (approximately 30 days at 1 second per block).
@ -111,6 +115,11 @@ var (
ErrVitaInvalidRole = errors.New("invalid core role")
ErrVitaInvalidResource = errors.New("invalid resource")
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.
@ -427,6 +436,50 @@ func newVita() *Vita {
manifest.NewParameter("updatedBy", smartcontract.Hash160Type))
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
}
@ -2260,3 +2313,265 @@ func (v *Vita) GetVestedUntil(d *dao.Simple, tokenID uint64) uint32 {
}
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
}

View File

@ -54,6 +54,20 @@ const (
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.
type Vita struct {
TokenID uint64 // Unique sequential identifier
@ -478,3 +492,148 @@ func (r *RecoveryRequest) FromStackItem(item stackitem.Item) error {
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
}
}