package memory import ( "context" "sort" "strings" "sync" "time" "telesrv/internal/domain" ) // Default page sizes applied when an admin filter leaves the limit unset. The // PostgreSQL queries page with LIMIT, so an unset limit has to resolve to a // finite page in both backends. const ( defaultCollectibleUsernameListLimit = 50 defaultCollectibleUsernameTransferLimit = 50 ) // CollectibleUsernameStore is the in-memory implementation of both // store.UsernameRegistryStore and store.CollectibleUsernameStore. RPC unit tests // run against it, so it reproduces every invariant migration 0150 encodes as an // index or CHECK constraint and returns the same domain errors PostgreSQL maps // its violations onto: // // - peer_usernames_peer_editable_idx: exactly one editable row per peer. // - peer_usernames_collectible_not_editable_check: a row backed by an asset is // never editable, so client-driven username edits cannot move an asset. // - peer_usernames.username_lower UNIQUE: global, case-insensitive name // uniqueness across users and channels, keyed by lower(username). // - collectible_usernames.username_lower UNIQUE: a name is minted at most // once. A burn therefore releases the registry row -- the name can be // occupied again as a peer username -- while the asset row keeps the name // for provenance and blocks a second mint. // - the status/owner CHECK pair: owner is populated exactly for status 'owned'. // - collectible_username_transfers_command_idx: command keys are globally // unique, which is what makes mint/transfer/revoke replay-safe. type CollectibleUsernameStore struct { mu sync.Mutex nextAssetID int64 nextTransferID int64 // assets is collectible_usernames keyed by identity. assets map[int64]domain.CollectibleUsername // assetsByName resolves a name onto the asset it currently stands for. After // migration 0152 uniqueness covers live rows only, so one name can accumulate // several burned rows plus at most one live row; this index points at the live // row when there is one and at the newest burned row otherwise, mirroring the // SQL lookup order. assetsByName map[string]int64 // registry is peer_usernames keyed by username_lower, which is exactly how // the table enforces global uniqueness. registry map[string]collectibleRegistryRow // transfers is the append-only provenance log per asset. transfers map[int64][]domain.CollectibleUsernameTransfer // commands maps a provenance command key onto the asset it touched. commands map[string]int64 // reserved, when set, is the operator blocklist consulted before a name is // assigned to an editable slot or minted, mirroring the PostgreSQL checks. reserved *ReservedUsernameStore } // WithReservedUsernames wires the operator blocklist into the registry so a // reserved name is refused, matching PostgreSQL. func (s *CollectibleUsernameStore) WithReservedUsernames(reserved *ReservedUsernameStore) *CollectibleUsernameStore { s.reserved = reserved return s } // nameReserved reports whether a name is on the operator blocklist. It touches // only s.reserved (its own lock), so it is safe from any context. func (s *CollectibleUsernameStore) nameReserved(usernameLower string) bool { if s == nil || s.reserved == nil { return false } r, _ := s.reserved.IsReserved(context.Background(), usernameLower) return r } // collectibleRegistryRow is one peer_usernames row: the owning peer plus the // projected username shape. type collectibleRegistryRow struct { peer domain.Peer row domain.Username } // NewCollectibleUsernameStore creates an empty registry. Asset ids start at 1 so // a zero CollectibleID keeps meaning "editable slot", matching the nullable // collectible_id column. func NewCollectibleUsernameStore() *CollectibleUsernameStore { return &CollectibleUsernameStore{ nextAssetID: 1, nextTransferID: 1, assets: make(map[int64]domain.CollectibleUsername), assetsByName: make(map[string]int64), registry: make(map[string]collectibleRegistryRow), transfers: make(map[int64][]domain.CollectibleUsernameTransfer), commands: make(map[string]int64), } } // SetEditableUsername writes the peer's editable slot, mirroring the // replace-then-insert the PostgreSQL user and channel stores run inside // account.updateUsername / channels.updateUsername. The memory backend keeps // usernames on the user and channel rows, so tests need this hook to give a peer // the editable registry row the projection expects. An empty username clears the // slot. func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer domain.Peer, username string) (bool, error) { if !validCollectibleUsernamePeer(peer) { return false, domain.ErrUsernameInvalid } username = domain.NormalizeUsername(username) s.mu.Lock() defer s.mu.Unlock() if username == "" { return s.clearEditableLocked(peer), nil } // peer_usernames has no length CHECK; the 5..32 editable rule lives in the // service layer. Only the character rules are a registry concern. if !domain.ValidCollectibleUsername(username) { return false, domain.ErrUsernameInvalid } key := strings.ToLower(username) if s.nameReserved(key) { return false, domain.ErrUsernameOccupied } if existing, ok := s.registry[key]; ok { if existing.peer == peer && existing.row.Editable { if existing.row.Username == username { return false, nil } existing.row.Username = username s.registry[key] = existing return true, nil } return false, domain.ErrUsernameOccupied } // A live asset owns its name even while it sits in the vault: only a burn // puts the name back into the free pool. if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned { return false, domain.ErrUsernameOccupied } s.clearEditableLocked(peer) s.registry[key] = collectibleRegistryRow{ peer: peer, row: domain.Username{ Username: username, Active: true, Editable: true, }, } return true, nil } // PeerUsernames returns the peer's registry rows in projection order. func (s *CollectibleUsernameStore) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) { s.mu.Lock() defer s.mu.Unlock() return s.peerUsernamesLocked(peer), nil } // PeerUsernamesBatch resolves several peers at once; peers holding no username // are absent from the result. func (s *CollectibleUsernameStore) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) { out := make(map[domain.Peer][]domain.Username, len(peers)) if len(peers) == 0 { return out, nil } s.mu.Lock() defer s.mu.Unlock() for _, peer := range peers { if !validCollectibleUsernamePeer(peer) { continue } if _, done := out[peer]; done { continue } rows := s.peerUsernamesLocked(peer) if len(rows) == 0 { continue } out[peer] = rows } return out, nil } // activeUsernamePeer resolves an active registry name for the memory user and // channel stores. Keeping lookup on the same registry that owns toggle/reorder // state prevents the test backend from silently falling back to scalar-only // behavior. func (s *CollectibleUsernameStore) activeUsernamePeer(username string, peerType domain.PeerType) (domain.Peer, bool) { key := strings.ToLower(domain.NormalizeUsername(username)) if key == "" { return domain.Peer{}, false } s.mu.Lock() defer s.mu.Unlock() entry, ok := s.registry[key] if !ok || !entry.row.Active || entry.peer.Type != peerType { return domain.Peer{}, false } return entry.peer, true } // activeUsernameMatches returns the best username rank for each peer: exact // matches precede prefix matches. Inactive rows stay occupied in the registry // but are deliberately absent from client search. func (s *CollectibleUsernameStore) activeUsernameMatches(query string, peerType domain.PeerType) map[int64]int { query = strings.ToLower(domain.NormalizeUsername(query)) if query == "" { return nil } s.mu.Lock() defer s.mu.Unlock() out := make(map[int64]int) for username, entry := range s.registry { if !entry.row.Active || entry.peer.Type != peerType || !strings.HasPrefix(username, query) { continue } rank := 1 if username == query { rank = 0 } if current, ok := out[entry.peer.ID]; !ok || rank < current { out[entry.peer.ID] = rank } } return out } func (s *CollectibleUsernameStore) peerHasActiveCollectibleUsername(peer domain.Peer) bool { s.mu.Lock() defer s.mu.Unlock() for _, entry := range s.registry { if entry.peer == peer && entry.row.Active && !entry.row.Editable { return true } } return false } // SetUsernameActive toggles one collectible row. The domain validator owns the // rules: the editable slot is off limits and a peer that holds usernames must // keep at least one active. func (s *CollectibleUsernameStore) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) { if !validCollectibleUsernamePeer(peer) { return false, domain.ErrUsernameInvalid } username = domain.NormalizeUsername(username) s.mu.Lock() defer s.mu.Unlock() current := s.peerUsernamesLocked(peer) if err := domain.ValidateUsernameToggle(current, username, active); err != nil { return false, err } key := strings.ToLower(username) entry := s.registry[key] if entry.row.Active == active { return false, nil } entry.row.Active = active s.registry[key] = entry return true, nil } // ReorderUsernames rewrites the peer's username sort order, editable slot // included. Validation and the resulting order both come from the domain helper, // so the two backends cannot drift. func (s *CollectibleUsernameStore) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) { if !validCollectibleUsernamePeer(peer) { return false, domain.ErrUsernameInvalid } s.mu.Lock() defer s.mu.Unlock() current := s.peerUsernamesLocked(peer) reordered, err := domain.ApplyUsernameReorder(current, order) if err != nil { return false, err } // Renumbering always happens; "changed" is about what a client can see. changed := !domain.SameUsernameOrder(current, reordered) for _, row := range reordered { key := strings.ToLower(row.Username) if key == "" { continue } entry := s.registry[key] if entry.row.SortOrder == row.SortOrder { continue } entry.row.SortOrder = row.SortOrder s.registry[key] = entry } return changed, nil } // DeactivateAllUsernames clears the active flag on every collectible row and // leaves the editable slot alone. func (s *CollectibleUsernameStore) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) { if !validCollectibleUsernamePeer(peer) { return false, domain.ErrUsernameInvalid } s.mu.Lock() defer s.mu.Unlock() changed := false for key, entry := range s.registry { if entry.peer != peer || !entry.row.Collectible() || !entry.row.Active { continue } entry.row.Active = false s.registry[key] = entry changed = true } return changed, nil } // MintCollectibleUsername creates the asset, optionally assigning it in the same // call. A replayed command key returns the recorded asset with created=false. func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) { req.Username = domain.NormalizeUsername(req.Username) if err := req.Validate(); err != nil { return domain.CollectibleUsername{}, false, err } s.mu.Lock() defer s.mu.Unlock() if asset, ok := s.replayLocked(req.CommandKey); ok { return asset, false, nil } key := strings.ToLower(req.Username) // Only a live asset occupies a name. A name whose history is entirely burned // is free to be issued again, and the new asset takes over the index entry // while the burned rows stay as provenance. if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned { return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied } if _, ok := s.registry[key]; ok { return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied } if s.nameReserved(key) { return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied } now := time.Now().UTC() purchaseDate := req.PurchaseDate if purchaseDate.IsZero() { purchaseDate = now } asset := domain.CollectibleUsername{ ID: s.nextAssetID, Username: req.Username, Status: domain.CollectibleUsernameStatusVault, PurchaseDate: purchaseDate, Currency: req.Currency, Amount: req.Amount, CryptoCurrency: req.CryptoCurrency, CryptoAmount: req.CryptoAmount, URL: req.URL, Version: 1, CreatedAt: now, UpdatedAt: now, } if req.Owner.Type != "" { asset.Status = domain.CollectibleUsernameStatusOwned asset.Owner = req.Owner // The first holder is the original owner and survives every later move. asset.OriginalOwner = req.Owner } if err := asset.Validate(); err != nil { return domain.CollectibleUsername{}, false, err } if asset.Owned() && s.countCollectiblesLocked(req.Owner) >= domain.MaxPeerCollectibleUsernames { return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit } s.nextAssetID++ s.assets[asset.ID] = asset s.assetsByName[key] = asset.ID if asset.Owned() { s.attachLocked(asset, req.Owner) } s.recordTransferLocked(domain.CollectibleUsernameTransfer{ CollectibleID: asset.ID, Kind: domain.CollectibleUsernameKindMint, To: req.Owner, Currency: req.Currency, Amount: req.Amount, Actor: req.Actor, Reason: req.Reason, CommandKey: req.CommandKey, CreatedAt: now, }) return asset, true, nil } // TransferCollectibleUsername moves the asset out of the vault or between // holders. Handing the asset to the peer that already holds it is a no-op. func (s *CollectibleUsernameStore) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) { req.Username = domain.NormalizeUsername(req.Username) if err := req.Validate(); err != nil { return domain.CollectibleUsername{}, false, err } s.mu.Lock() defer s.mu.Unlock() if asset, ok := s.replayLocked(req.CommandKey); ok { return asset, false, nil } asset, err := s.assetByNameLocked(req.Username) if err != nil { return domain.CollectibleUsername{}, false, err } if asset.Status == domain.CollectibleUsernameStatusBurned { return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned } if asset.Owned() && asset.Owner == req.To { return asset, false, nil } key := strings.ToLower(asset.Username) // Defensive: the registry row for this name must be the asset's own. A live // asset occupies its name globally, so the only way another row can hold it // is an inconsistently seeded store. if existing, ok := s.registry[key]; ok && existing.row.CollectibleID != asset.ID { return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied } if s.countCollectiblesLocked(req.To) >= domain.MaxPeerCollectibleUsernames { return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit } from := asset.Owner now := time.Now().UTC() asset.Status = domain.CollectibleUsernameStatusOwned asset.Owner = req.To if asset.OriginalOwner.Type == "" { asset.OriginalOwner = req.To } asset.TransferCount++ asset.Version++ asset.UpdatedAt = now if err := asset.Validate(); err != nil { return domain.CollectibleUsername{}, false, err } s.assets[asset.ID] = asset s.detachLocked(asset.ID) s.attachLocked(asset, req.To) s.recordTransferLocked(domain.CollectibleUsernameTransfer{ CollectibleID: asset.ID, Kind: domain.CollectibleUsernameKindTransfer, From: from, To: req.To, Actor: req.Actor, Reason: req.Reason, CommandKey: req.CommandKey, CreatedAt: now, }) return asset, true, nil } // RevokeCollectibleUsername returns the asset to the vault, or burns it. // // A revoke keeps the name owned by the asset -- nobody else can take it -- while // a burn drops the registry row and releases the name back to the free pool. The // burned asset row itself survives with its name so provenance stays readable // and the name never mints twice. func (s *CollectibleUsernameStore) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) { req.Username = domain.NormalizeUsername(req.Username) if err := req.Validate(); err != nil { return domain.CollectibleUsername{}, false, err } s.mu.Lock() defer s.mu.Unlock() if asset, ok := s.replayLocked(req.CommandKey); ok { return asset, false, nil } asset, err := s.assetByNameLocked(req.Username) if err != nil { return domain.CollectibleUsername{}, false, err } if asset.Status == domain.CollectibleUsernameStatusBurned { return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned } if !req.Burn && !asset.Owned() { return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotOwned } from := asset.Owner now := time.Now().UTC() kind := domain.CollectibleUsernameKindRevoke asset.Status = domain.CollectibleUsernameStatusVault if req.Burn { kind = domain.CollectibleUsernameKindBurn asset.Status = domain.CollectibleUsernameStatusBurned } asset.Owner = domain.Peer{} asset.Version++ asset.UpdatedAt = now if err := asset.Validate(); err != nil { return domain.CollectibleUsername{}, false, err } s.assets[asset.ID] = asset s.detachLocked(asset.ID) s.recordTransferLocked(domain.CollectibleUsernameTransfer{ CollectibleID: asset.ID, Kind: kind, From: from, Actor: req.Actor, Reason: req.Reason, CommandKey: req.CommandKey, CreatedAt: now, }) return asset, true, nil } // CollectibleUsername looks the asset up by name, case-insensitively. // DeleteCollectibleUsername removes the live asset for a name completely -- // registry row, asset and provenance -- and frees the name for any use. Revoke // with Burn retires an asset but keeps its history; this is the escape hatch for // an asset issued by mistake. // // A command key cannot make this idempotent: the record it would resolve to is // gone. A repeated call therefore reports deleted=false once no live asset is // left, which is also what a delete of a burned-only name reports. func (s *CollectibleUsernameStore) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) { if s == nil { return false, nil } req.Username = domain.NormalizeUsername(req.Username) req.Actor = strings.TrimSpace(req.Actor) req.Reason = strings.TrimSpace(req.Reason) req.CommandKey = strings.TrimSpace(req.CommandKey) if err := req.Validate(); err != nil { return false, err } s.mu.Lock() defer s.mu.Unlock() key := strings.ToLower(req.Username) id, ok := s.assetsByName[key] if !ok { return false, nil } asset, ok := s.assets[id] if !ok || asset.Status == domain.CollectibleUsernameStatusBurned { return false, nil } s.detachLocked(id) delete(s.assets, id) delete(s.transfers, id) for commandKey, target := range s.commands { if target == id { delete(s.commands, commandKey) } } s.rebindAssetNameLocked(key) return true, nil } // rebindAssetNameLocked re-points the name index after a row disappears: the // newest remaining row wins, and the entry is dropped when none is left. func (s *CollectibleUsernameStore) rebindAssetNameLocked(key string) { best := int64(0) for id, asset := range s.assets { if strings.ToLower(asset.Username) != key { continue } if best == 0 || id > best { best = id } } if best == 0 { delete(s.assetsByName, key) return } s.assetsByName[key] = best } func (s *CollectibleUsernameStore) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) { s.mu.Lock() defer s.mu.Unlock() return s.assetByNameLocked(domain.NormalizeUsername(username)) } // CollectibleUsernameByID looks the asset up by identity. func (s *CollectibleUsernameStore) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) { s.mu.Lock() defer s.mu.Unlock() asset, ok := s.assets[id] if !ok { return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound } return asset, nil } // ListCollectibleUsernames is the admin listing query: newest first, paged by a // BeforeID keyset, matching collectible_usernames_status_idx ordering. func (s *CollectibleUsernameStore) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) { if filter.Status != "" && !filter.Status.Valid() { return nil, domain.ErrCollectibleUsernameStateInvalid } limit := filter.Limit if limit <= 0 { limit = defaultCollectibleUsernameListLimit } query := strings.ToLower(domain.NormalizeUsername(filter.Query)) s.mu.Lock() defer s.mu.Unlock() out := make([]domain.CollectibleUsername, 0, len(s.assets)) for _, asset := range s.assets { if filter.Status != "" && asset.Status != filter.Status { continue } if filter.Owner.Type != "" && asset.Owner != filter.Owner { continue } if query != "" && !strings.Contains(strings.ToLower(asset.Username), query) { continue } if filter.BeforeID > 0 && asset.ID >= filter.BeforeID { continue } out = append(out, asset) } sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) if len(out) > limit { out = out[:limit] } return out, nil } // CollectibleUsernameTransfers returns the provenance log newest first. func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) { if limit <= 0 { limit = defaultCollectibleUsernameTransferLimit } s.mu.Lock() defer s.mu.Unlock() stored := s.transfers[collectibleID] out := make([]domain.CollectibleUsernameTransfer, 0, len(stored)) for i := len(stored) - 1; i >= 0 && len(out) < limit; i-- { out = append(out, stored[i]) } return out, nil } // peerUsernamesLocked collects the peer's rows in projection order. The rows are // values, so the returned slice cannot be used to mutate stored state. func (s *CollectibleUsernameStore) peerUsernamesLocked(peer domain.Peer) []domain.Username { rows := make([]domain.Username, 0, 4) for _, entry := range s.registry { if entry.peer != peer { continue } rows = append(rows, entry.row) } return domain.SortUsernames(rows) } // countCollectiblesLocked counts the peer's collectible registry rows, which is // what MaxPeerCollectibleUsernames bounds. func (s *CollectibleUsernameStore) countCollectiblesLocked(peer domain.Peer) int { count := 0 for _, entry := range s.registry { if entry.peer == peer && entry.row.Collectible() { count++ } } return count } // nextSortOrderLocked appends the new collectible after the peer's existing ones, // clamped by the registry sort_order CHECK. func (s *CollectibleUsernameStore) nextSortOrderLocked(peer domain.Peer) int { next := 0 for _, entry := range s.registry { if entry.peer != peer || !entry.row.Collectible() { continue } if entry.row.SortOrder >= next { next = entry.row.SortOrder + 1 } } if next > domain.MaxUsernameSortOrder { next = domain.MaxUsernameSortOrder } return next } // attachLocked projects an owned asset into the registry. Callers check // occupancy and the per-peer bound first, exactly like the PostgreSQL path does // before it hits the unique index. func (s *CollectibleUsernameStore) attachLocked(asset domain.CollectibleUsername, peer domain.Peer) { s.registry[strings.ToLower(asset.Username)] = collectibleRegistryRow{ peer: peer, row: domain.Username{ Username: asset.Username, Active: true, Editable: false, SortOrder: s.nextSortOrderLocked(peer), CollectibleID: asset.ID, }, } } // detachLocked removes the registry row backed by the asset, leaving the peer's // editable slot and its other collectibles untouched. func (s *CollectibleUsernameStore) detachLocked(collectibleID int64) { for key, entry := range s.registry { if entry.row.CollectibleID == collectibleID { delete(s.registry, key) return } } } // clearEditableLocked drops the peer's editable row, keeping the one-editable-row // index true by construction. func (s *CollectibleUsernameStore) clearEditableLocked(peer domain.Peer) bool { for key, entry := range s.registry { if entry.peer == peer && entry.row.Editable { delete(s.registry, key) return true } } return false } func (s *CollectibleUsernameStore) assetByNameLocked(username string) (domain.CollectibleUsername, error) { if username == "" { return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound } id, ok := s.assetsByName[strings.ToLower(username)] if !ok { return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound } asset, ok := s.assets[id] if !ok { return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound } return asset, nil } // replayLocked resolves a command key onto the asset the recorded command // touched. The provenance command index is global, so a replayed key is a no-op // for every kind, just like the INSERT ... ON CONFLICT DO NOTHING path. func (s *CollectibleUsernameStore) replayLocked(commandKey string) (domain.CollectibleUsername, bool) { if commandKey == "" { return domain.CollectibleUsername{}, false } id, ok := s.commands[commandKey] if !ok { return domain.CollectibleUsername{}, false } asset, ok := s.assets[id] return asset, ok } func (s *CollectibleUsernameStore) recordTransferLocked(entry domain.CollectibleUsernameTransfer) { entry.ID = s.nextTransferID s.nextTransferID++ s.transfers[entry.CollectibleID] = append(s.transfers[entry.CollectibleID], entry) if entry.CommandKey != "" { s.commands[entry.CommandKey] = entry.CollectibleID } } // validCollectibleUsernamePeer mirrors the peer_type CHECK: only real user and // channel peers can hold a username. func validCollectibleUsernamePeer(peer domain.Peer) bool { switch peer.Type { case domain.PeerTypeUser, domain.PeerTypeChannel: return peer.ID > 0 default: return false } }