feat(chain): remove channels, add social feed (Phase A of v2.0.0)
Replaces the channel/membership model with a VK/Twitter-style feed:
public posts, follow graph, likes. Views are deliberately off-chain
(counted by the hosting relay, Phase B).
Removed
- EventCreateChannel, EventAddMember
- CreateChannelPayload, AddMemberPayload, ChannelMember
- prefixChannel, prefixChanMember
- chain.Channel(), chain.ChannelMembers()
- node/api_channels.go
- GetChannel, GetChannelMembers on ExplorerQuery
Added
- Events: CREATE_POST, DELETE_POST, FOLLOW, UNFOLLOW, LIKE_POST, UNLIKE_POST
- Payloads: CreatePostPayload, DeletePostPayload, FollowPayload,
UnfollowPayload, LikePostPayload, UnlikePostPayload
- Stored shape: PostRecord (author, size, hash, hosting relay, timestamp,
reply/quote refs, soft-delete flag, fee paid)
- State prefixes: post:, postbyauthor:, follow:, followin:, like:, likecount:
- Queries: Post(), PostsByAuthor(), Following(), Followers(),
LikeCount(), HasLiked()
- Cached like counter via bumpLikeCount helper
Pricing
- BasePostFee = 1000 µT (aligned with MinFee block-validation floor)
- PostByteFee = 1 µT/byte of compressed content
- Total fee credited in full to HostingRelay pub (storage compensation)
- MaxPostSize = 256 KiB
Integrity
- CREATE_POST validates content_hash length (32 B) and size range
- DELETE_POST restricted to post.Author
- Duplicate FOLLOW / LIKE rejected
- reply_to and quote_of mutually exclusive
Tests
- TestFeedCreatePost: post stored, indexed, host credited
- TestFeedInsufficientFee: underpaid post is skipped
- TestFeedFollowUnfollow: follow graph round-trips via forward + inbound indices
- TestFeedLikeUnlike: like toggles with dedup, counter stays accurate
- TestFeedDeletePostByOther: non-author deletion rejected
This is Phase A (chain-layer). Phase B adds the relay feed-mailbox
(post bodies + gossipsub) and HTTP endpoints. Phase C adds the client
Feed tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,8 +40,14 @@ const (
|
|||||||
prefixHeight = "height" // height → uint64
|
prefixHeight = "height" // height → uint64
|
||||||
prefixBalance = "balance:" // balance:<pubkey> → uint64
|
prefixBalance = "balance:" // balance:<pubkey> → uint64
|
||||||
prefixIdentity = "id:" // id:<pubkey> → RegisterKeyPayload JSON
|
prefixIdentity = "id:" // id:<pubkey> → RegisterKeyPayload JSON
|
||||||
prefixChannel = "chan:" // chan:<channelID> → CreateChannelPayload JSON
|
|
||||||
prefixChanMember = "chan-member:" // chan-member:<channelID>:<memberPubKey> → "" (presence = member)
|
// Social feed (v2.0.0). Replaced the old channel keys (chan:, chan-member:).
|
||||||
|
prefixPost = "post:" // post:<postID> → PostRecord JSON
|
||||||
|
prefixPostByAuthor = "postbyauthor:" // postbyauthor:<author>:<ts_20d>:<postID> → "" (chrono index)
|
||||||
|
prefixFollow = "follow:" // follow:<follower>:<target> → "" (presence = follows)
|
||||||
|
prefixFollowInbound = "followin:" // followin:<target>:<follower> → "" (reverse index — counts followers)
|
||||||
|
prefixLike = "like:" // like:<postID>:<liker> → "" (presence = liked)
|
||||||
|
prefixLikeCount = "likecount:" // likecount:<postID> → uint64 (cached count)
|
||||||
prefixWalletBind = "walletbind:" // walletbind:<node_pubkey> → wallet_pubkey (string)
|
prefixWalletBind = "walletbind:" // walletbind:<node_pubkey> → wallet_pubkey (string)
|
||||||
prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON
|
prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON
|
||||||
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
|
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
|
||||||
@@ -538,11 +544,13 @@ func (c *Chain) Identity(pubKeyHex string) (*RegisterKeyPayload, error) {
|
|||||||
return &p, err
|
return &p, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel returns the CreateChannelPayload for a channel ID, or nil.
|
// ── Feed queries (v2.0.0) ──────────────────────────────────────────────────
|
||||||
func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
|
|
||||||
var p CreateChannelPayload
|
// Post returns the PostRecord for a post ID, or nil if not found.
|
||||||
|
func (c *Chain) Post(postID string) (*PostRecord, error) {
|
||||||
|
var p PostRecord
|
||||||
err := c.db.View(func(txn *badger.Txn) error {
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
item, err := txn.Get([]byte(prefixChannel + channelID))
|
item, err := txn.Get([]byte(prefixPost + postID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -553,13 +561,62 @@ func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
|
|||||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return &p, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMembers returns the public keys of all members added to channelID.
|
// PostsByAuthor returns the last `limit` posts by the given author, newest
|
||||||
func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
|
// first. Iterates `postbyauthor:<author>:...` in reverse order. If limit
|
||||||
prefix := []byte(fmt.Sprintf("%s%s:", prefixChanMember, channelID))
|
// ≤ 0, defaults to 50; capped at 200.
|
||||||
var members []string
|
func (c *Chain) PostsByAuthor(authorPub string, limit int) ([]*PostRecord, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
prefix := []byte(prefixPostByAuthor + authorPub + ":")
|
||||||
|
var out []*PostRecord
|
||||||
|
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Prefix = prefix
|
||||||
|
opts.Reverse = true // newest (higher ts) first — reverse iteration
|
||||||
|
opts.PrefetchValues = false
|
||||||
|
// For reverse iteration Badger requires seeking past the prefix range.
|
||||||
|
seek := append([]byte{}, prefix...)
|
||||||
|
seek = append(seek, 0xff)
|
||||||
|
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
for it.Seek(seek); it.ValidForPrefix(prefix) && len(out) < limit; it.Next() {
|
||||||
|
key := string(it.Item().Key())
|
||||||
|
// key = "postbyauthor:<author>:<ts_20d>:<postID>"
|
||||||
|
parts := strings.Split(key, ":")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
postID := parts[len(parts)-1]
|
||||||
|
rec, err := c.postInTxn(txn, postID)
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Following returns the Ed25519 pubkeys that `follower` subscribes to.
|
||||||
|
func (c *Chain) Following(followerPub string) ([]string, error) {
|
||||||
|
prefix := []byte(prefixFollow + followerPub + ":")
|
||||||
|
var out []string
|
||||||
err := c.db.View(func(txn *badger.Txn) error {
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
opts := badger.DefaultIteratorOptions
|
opts := badger.DefaultIteratorOptions
|
||||||
opts.PrefetchValues = false
|
opts.PrefetchValues = false
|
||||||
@@ -568,15 +625,95 @@ func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
|
|||||||
defer it.Close()
|
defer it.Close()
|
||||||
for it.Rewind(); it.Valid(); it.Next() {
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
key := string(it.Item().Key())
|
key := string(it.Item().Key())
|
||||||
// key = "chan-member:<channelID>:<memberPubKey>"
|
// key = "follow:<follower>:<target>"
|
||||||
parts := strings.SplitN(key, ":", 3)
|
parts := strings.SplitN(key, ":", 3)
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
members = append(members, parts[2])
|
out = append(out, parts[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return members, err
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Followers returns the Ed25519 pubkeys that follow `target`.
|
||||||
|
func (c *Chain) Followers(targetPub string) ([]string, error) {
|
||||||
|
prefix := []byte(prefixFollowInbound + targetPub + ":")
|
||||||
|
var out []string
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.PrefetchValues = false
|
||||||
|
opts.Prefix = prefix
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
key := string(it.Item().Key())
|
||||||
|
parts := strings.SplitN(key, ":", 3)
|
||||||
|
if len(parts) == 3 {
|
||||||
|
out = append(out, parts[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikeCount returns the cached count of likes for a post (O(1)).
|
||||||
|
func (c *Chain) LikeCount(postID string) (uint64, error) {
|
||||||
|
var count uint64
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
item, err := txn.Get([]byte(prefixLikeCount + postID))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(val []byte) error {
|
||||||
|
if len(val) == 8 {
|
||||||
|
count = binary.BigEndian.Uint64(val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLiked reports whether `liker` has liked the given post.
|
||||||
|
func (c *Chain) HasLiked(postID, likerPub string) (bool, error) {
|
||||||
|
key := []byte(prefixLike + postID + ":" + likerPub)
|
||||||
|
var ok bool
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
_, err := txn.Get(key)
|
||||||
|
if err == nil {
|
||||||
|
ok = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// postInTxn is the internal helper used by iteration paths to fetch a full
|
||||||
|
// PostRecord without opening a new View transaction.
|
||||||
|
func (c *Chain) postInTxn(txn *badger.Txn, postID string) (*PostRecord, error) {
|
||||||
|
item, err := txn.Get([]byte(prefixPost + postID))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p PostRecord
|
||||||
|
if err := item.Value(func(val []byte) error {
|
||||||
|
return json.Unmarshal(val, &p)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WalletBinding returns the payout wallet pub key bound to a node, or "" if none.
|
// WalletBinding returns the payout wallet pub key bound to a node, or "" if none.
|
||||||
@@ -741,41 +878,197 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
case EventCreateChannel:
|
// ── Feed events (v2.0.0) ──────────────────────────────────────────
|
||||||
var p CreateChannelPayload
|
case EventCreatePost:
|
||||||
|
var p CreatePostPayload
|
||||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
return 0, fmt.Errorf("%w: CREATE_CHANNEL bad payload: %v", ErrTxFailed, err)
|
return 0, fmt.Errorf("%w: CREATE_POST bad payload: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if p.PostID == "" {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: post_id required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if len(p.ContentHash) != 32 {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: content_hash must be 32 bytes", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if p.HostingRelay == "" {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: hosting_relay required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if p.Size == 0 || p.Size > MaxPostSize {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: size %d out of range (0, %d]",
|
||||||
|
ErrTxFailed, p.Size, MaxPostSize)
|
||||||
|
}
|
||||||
|
if p.ReplyTo != "" && p.QuoteOf != "" {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: reply_to and quote_of are mutually exclusive", ErrTxFailed)
|
||||||
|
}
|
||||||
|
// Duplicate check — same post_id may only commit once.
|
||||||
|
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err == nil {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: post %s already exists", ErrTxFailed, p.PostID)
|
||||||
|
}
|
||||||
|
// Fee formula: BasePostFee + size × PostByteFee. tx.Fee carries the
|
||||||
|
// full amount; we validate it matches and the sender can afford it.
|
||||||
|
expectedFee := BasePostFee + p.Size*PostByteFee
|
||||||
|
if tx.Fee < expectedFee {
|
||||||
|
return 0, fmt.Errorf("%w: CREATE_POST: fee %d < required %d (base %d + %d × %d bytes)",
|
||||||
|
ErrTxFailed, tx.Fee, expectedFee, BasePostFee, PostByteFee, p.Size)
|
||||||
}
|
}
|
||||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
return 0, fmt.Errorf("CREATE_CHANNEL debit: %w", err)
|
return 0, fmt.Errorf("CREATE_POST debit: %w", err)
|
||||||
}
|
}
|
||||||
val, _ := json.Marshal(p)
|
// Full fee goes to the hosting relay (storage compensation). No
|
||||||
if err := txn.Set([]byte(prefixChannel+p.ChannelID), val); err != nil {
|
// validator cut on posts — validators earn from other tx types. This
|
||||||
|
// incentivises nodes to actually host posts.
|
||||||
|
relayTarget, err := c.resolveRewardTarget(txn, p.HostingRelay)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := c.creditBalance(txn, relayTarget, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("credit hosting relay: %w", err)
|
||||||
|
}
|
||||||
|
rec := PostRecord{
|
||||||
|
PostID: p.PostID,
|
||||||
|
Author: tx.From,
|
||||||
|
ContentHash: p.ContentHash,
|
||||||
|
Size: p.Size,
|
||||||
|
HostingRelay: p.HostingRelay,
|
||||||
|
ReplyTo: p.ReplyTo,
|
||||||
|
QuoteOf: p.QuoteOf,
|
||||||
|
CreatedAt: tx.Timestamp.Unix(),
|
||||||
|
FeeUT: tx.Fee,
|
||||||
|
}
|
||||||
|
recBytes, _ := json.Marshal(rec)
|
||||||
|
if err := txn.Set([]byte(prefixPost+p.PostID), recBytes); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Chrono index — allows PostsByAuthor to list newest-first in O(N).
|
||||||
|
idxKey := fmt.Sprintf("%s%s:%020d:%s", prefixPostByAuthor, tx.From, rec.CreatedAt, p.PostID)
|
||||||
|
if err := txn.Set([]byte(idxKey), []byte{}); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
case EventAddMember:
|
case EventDeletePost:
|
||||||
var p AddMemberPayload
|
var p DeletePostPayload
|
||||||
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
return 0, fmt.Errorf("%w: ADD_MEMBER bad payload: %v", ErrTxFailed, err)
|
return 0, fmt.Errorf("%w: DELETE_POST bad payload: %v", ErrTxFailed, err)
|
||||||
}
|
}
|
||||||
if p.ChannelID == "" {
|
if p.PostID == "" {
|
||||||
return 0, fmt.Errorf("%w: ADD_MEMBER: channel_id required", ErrTxFailed)
|
return 0, fmt.Errorf("%w: DELETE_POST: post_id required", ErrTxFailed)
|
||||||
}
|
}
|
||||||
if _, err := txn.Get([]byte(prefixChannel + p.ChannelID)); err != nil {
|
item, err := txn.Get([]byte(prefixPost + p.PostID))
|
||||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
return 0, fmt.Errorf("%w: ADD_MEMBER: channel %q not found", ErrTxFailed, p.ChannelID)
|
return 0, fmt.Errorf("%w: DELETE_POST: post %s not found", ErrTxFailed, p.PostID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var rec PostRecord
|
||||||
|
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if rec.Author != tx.From {
|
||||||
|
return 0, fmt.Errorf("%w: DELETE_POST: only author can delete", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("DELETE_POST debit: %w", err)
|
||||||
|
}
|
||||||
|
rec.Deleted = true
|
||||||
|
val, _ := json.Marshal(rec)
|
||||||
|
if err := txn.Set([]byte(prefixPost+p.PostID), val); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventFollow:
|
||||||
|
if tx.To == "" {
|
||||||
|
return 0, fmt.Errorf("%w: FOLLOW: target (to) is required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if tx.To == tx.From {
|
||||||
|
return 0, fmt.Errorf("%w: FOLLOW: cannot follow yourself", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("FOLLOW debit: %w", err)
|
||||||
|
}
|
||||||
|
// follow:<follower>:<target> + reverse index followin:<target>:<follower>
|
||||||
|
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
|
||||||
|
if _, err := txn.Get(fKey); err == nil {
|
||||||
|
return 0, fmt.Errorf("%w: FOLLOW: already following", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := txn.Set(fKey, []byte{}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := txn.Set([]byte(prefixFollowInbound+tx.To+":"+tx.From), []byte{}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventUnfollow:
|
||||||
|
if tx.To == "" {
|
||||||
|
return 0, fmt.Errorf("%w: UNFOLLOW: target (to) is required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("UNFOLLOW debit: %w", err)
|
||||||
|
}
|
||||||
|
fKey := []byte(prefixFollow + tx.From + ":" + tx.To)
|
||||||
|
if _, err := txn.Get(fKey); err != nil {
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return 0, fmt.Errorf("%w: UNFOLLOW: not following", ErrTxFailed)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := txn.Delete(fKey); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := txn.Delete([]byte(prefixFollowInbound + tx.To + ":" + tx.From)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventLikePost:
|
||||||
|
var p LikePostPayload
|
||||||
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: LIKE_POST bad payload: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if p.PostID == "" {
|
||||||
|
return 0, fmt.Errorf("%w: LIKE_POST: post_id required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if _, err := txn.Get([]byte(prefixPost + p.PostID)); err != nil {
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return 0, fmt.Errorf("%w: LIKE_POST: post %s not found", ErrTxFailed, p.PostID)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
|
||||||
|
if _, err := txn.Get(lKey); err == nil {
|
||||||
|
return 0, fmt.Errorf("%w: LIKE_POST: already liked", ErrTxFailed)
|
||||||
|
}
|
||||||
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
|
return 0, fmt.Errorf("LIKE_POST debit: %w", err)
|
||||||
|
}
|
||||||
|
if err := txn.Set(lKey, []byte{}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := bumpLikeCount(txn, p.PostID, +1); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventUnlikePost:
|
||||||
|
var p UnlikePostPayload
|
||||||
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: UNLIKE_POST bad payload: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
if p.PostID == "" {
|
||||||
|
return 0, fmt.Errorf("%w: UNLIKE_POST: post_id required", ErrTxFailed)
|
||||||
|
}
|
||||||
|
lKey := []byte(prefixLike + p.PostID + ":" + tx.From)
|
||||||
|
if _, err := txn.Get(lKey); err != nil {
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return 0, fmt.Errorf("%w: UNLIKE_POST: not liked", ErrTxFailed)
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
|
||||||
return 0, fmt.Errorf("ADD_MEMBER debit: %w", err)
|
return 0, fmt.Errorf("UNLIKE_POST debit: %w", err)
|
||||||
}
|
}
|
||||||
member := tx.To
|
if err := txn.Delete(lKey); err != nil {
|
||||||
if member == "" {
|
return 0, err
|
||||||
member = tx.From
|
|
||||||
}
|
}
|
||||||
if err := txn.Set([]byte(fmt.Sprintf("%s%s:%s", prefixChanMember, p.ChannelID, member)), []byte{}); err != nil {
|
if err := bumpLikeCount(txn, p.PostID, -1); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2336,6 +2629,35 @@ func (c *Chain) isValidatorTxn(txn *badger.Txn, pubKey string) (bool, error) {
|
|||||||
|
|
||||||
// verifyEd25519 verifies an Ed25519 signature without importing the identity package
|
// verifyEd25519 verifies an Ed25519 signature without importing the identity package
|
||||||
// (which would create a circular dependency).
|
// (which would create a circular dependency).
|
||||||
|
// bumpLikeCount adjusts the cached like counter for a post. delta = ±1.
|
||||||
|
// Clamps at zero so a corrupt unlike without prior like can't underflow.
|
||||||
|
func bumpLikeCount(txn *badger.Txn, postID string, delta int64) error {
|
||||||
|
key := []byte(prefixLikeCount + postID)
|
||||||
|
var cur uint64
|
||||||
|
item, err := txn.Get(key)
|
||||||
|
if err == nil {
|
||||||
|
if verr := item.Value(func(val []byte) error {
|
||||||
|
if len(val) == 8 {
|
||||||
|
cur = binary.BigEndian.Uint64(val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); verr != nil {
|
||||||
|
return verr
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case delta < 0 && cur > 0:
|
||||||
|
cur--
|
||||||
|
case delta > 0:
|
||||||
|
cur++
|
||||||
|
}
|
||||||
|
var buf [8]byte
|
||||||
|
binary.BigEndian.PutUint64(buf[:], cur)
|
||||||
|
return txn.Set(key, buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) {
|
func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) {
|
||||||
pubBytes, err := hex.DecodeString(pubKeyHex)
|
pubBytes, err := hex.DecodeString(pubKeyHex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -794,3 +794,248 @@ var _ = identity.Generate
|
|||||||
// Ensure ed25519 and hex are used directly (they may be used via helpers).
|
// Ensure ed25519 and hex are used directly (they may be used via helpers).
|
||||||
var _ = ed25519.PublicKey(nil)
|
var _ = ed25519.PublicKey(nil)
|
||||||
var _ = hex.EncodeToString
|
var _ = hex.EncodeToString
|
||||||
|
|
||||||
|
// ── Feed (v2.0.0) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestFeedCreatePost: post commits, indexes, credits the hosting relay.
|
||||||
|
func TestFeedCreatePost(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t) // post author
|
||||||
|
host := newIdentity(t) // hosting relay pubkey
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Fund alice + host.
|
||||||
|
const postSize = uint64(200)
|
||||||
|
expectedFee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
expectedFee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
time.Sleep(2 * time.Millisecond) // ensure distinct txID (nanosec clock)
|
||||||
|
fundHost := makeTx(blockchain.EventTransfer, val.PubKeyHex(), host.PubKeyHex(),
|
||||||
|
blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundHost})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
hostBalBefore, _ := c.Balance(host.PubKeyHex())
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte("hello world post body"))
|
||||||
|
postPayload := blockchain.CreatePostPayload{
|
||||||
|
PostID: "post1",
|
||||||
|
ContentHash: h[:],
|
||||||
|
Size: postSize,
|
||||||
|
HostingRelay: host.PubKeyHex(),
|
||||||
|
}
|
||||||
|
postTx := makeTx(
|
||||||
|
blockchain.EventCreatePost,
|
||||||
|
alice.PubKeyHex(), "",
|
||||||
|
0, expectedFee, // Fee = base + size*byte_fee; amount = 0
|
||||||
|
mustJSON(postPayload),
|
||||||
|
)
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
rec, err := c.Post("post1")
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
t.Fatalf("Post(\"post1\") = %v, %v; want record", rec, err)
|
||||||
|
}
|
||||||
|
if rec.Author != alice.PubKeyHex() {
|
||||||
|
t.Errorf("author: got %q want %q", rec.Author, alice.PubKeyHex())
|
||||||
|
}
|
||||||
|
if rec.Size != postSize {
|
||||||
|
t.Errorf("size: got %d want %d", rec.Size, postSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host should have been credited the full fee.
|
||||||
|
hostBalAfter, _ := c.Balance(host.PubKeyHex())
|
||||||
|
if hostBalAfter != hostBalBefore+expectedFee {
|
||||||
|
t.Errorf("host balance: got %d, want %d (delta %d)",
|
||||||
|
hostBalAfter, hostBalBefore, expectedFee)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsByAuthor should list it.
|
||||||
|
posts, err := c.PostsByAuthor(alice.PubKeyHex(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PostsByAuthor: %v", err)
|
||||||
|
}
|
||||||
|
if len(posts) != 1 || posts[0].PostID != "post1" {
|
||||||
|
t.Errorf("PostsByAuthor: got %v, want [post1]", posts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedInsufficientFee: size-based fee is enforced.
|
||||||
|
func TestFeedInsufficientFee(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
host := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
10*blockchain.Token, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
const postSize = uint64(1000)
|
||||||
|
h := sha256.Sum256([]byte("body"))
|
||||||
|
postPayload := blockchain.CreatePostPayload{
|
||||||
|
PostID: "underpaid",
|
||||||
|
ContentHash: h[:],
|
||||||
|
Size: postSize,
|
||||||
|
HostingRelay: host.PubKeyHex(),
|
||||||
|
}
|
||||||
|
// Fee too low — base alone without the size component.
|
||||||
|
// (Must still be ≥ MinFee so the chain-level block validation passes;
|
||||||
|
// the per-event CREATE_POST check is what should reject it.)
|
||||||
|
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "",
|
||||||
|
0, blockchain.MinFee, mustJSON(postPayload))
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
|
||||||
|
mustAddBlock(t, c, b2) // block commits, the tx is skipped (logged)
|
||||||
|
|
||||||
|
if rec, _ := c.Post("underpaid"); rec != nil {
|
||||||
|
t.Fatalf("post was stored despite insufficient fee: %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedFollowUnfollow: follow graph round-trips via indices.
|
||||||
|
func TestFeedFollowUnfollow(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
bob := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
followTx := makeTx(blockchain.EventFollow, alice.PubKeyHex(), bob.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.FollowPayload{}))
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{followTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
following, _ := c.Following(alice.PubKeyHex())
|
||||||
|
if len(following) != 1 || following[0] != bob.PubKeyHex() {
|
||||||
|
t.Errorf("Following: got %v, want [%s]", following, bob.PubKeyHex())
|
||||||
|
}
|
||||||
|
followers, _ := c.Followers(bob.PubKeyHex())
|
||||||
|
if len(followers) != 1 || followers[0] != alice.PubKeyHex() {
|
||||||
|
t.Errorf("Followers: got %v, want [%s]", followers, alice.PubKeyHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfollow.
|
||||||
|
unfollowTx := makeTx(blockchain.EventUnfollow, alice.PubKeyHex(), bob.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.UnfollowPayload{}))
|
||||||
|
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{unfollowTx})
|
||||||
|
mustAddBlock(t, c, b3)
|
||||||
|
|
||||||
|
following, _ = c.Following(alice.PubKeyHex())
|
||||||
|
if len(following) != 0 {
|
||||||
|
t.Errorf("Following after unfollow: got %v, want []", following)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedLikeUnlike: like toggles + cached count stays consistent.
|
||||||
|
func TestFeedLikeUnlike(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t) // author
|
||||||
|
bob := newIdentity(t) // liker
|
||||||
|
host := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
const postSize = uint64(100)
|
||||||
|
expectedPostFee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
expectedPostFee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
fundBob := makeTx(blockchain.EventTransfer, val.PubKeyHex(), bob.PubKeyHex(),
|
||||||
|
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundBob})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte("likeable"))
|
||||||
|
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "",
|
||||||
|
0, expectedPostFee,
|
||||||
|
mustJSON(blockchain.CreatePostPayload{
|
||||||
|
PostID: "p1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(),
|
||||||
|
}))
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
likeTx := makeTx(blockchain.EventLikePost, bob.PubKeyHex(), "",
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.LikePostPayload{PostID: "p1"}))
|
||||||
|
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{likeTx})
|
||||||
|
mustAddBlock(t, c, b3)
|
||||||
|
|
||||||
|
n, _ := c.LikeCount("p1")
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("LikeCount after like: got %d, want 1", n)
|
||||||
|
}
|
||||||
|
liked, _ := c.HasLiked("p1", bob.PubKeyHex())
|
||||||
|
if !liked {
|
||||||
|
t.Errorf("HasLiked after like: got false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate like — tx is skipped; counter stays at 1.
|
||||||
|
dupTx := makeTx(blockchain.EventLikePost, bob.PubKeyHex(), "",
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.LikePostPayload{PostID: "p1"}))
|
||||||
|
b4 := buildBlock(t, b3, val, []*blockchain.Transaction{dupTx})
|
||||||
|
mustAddBlock(t, c, b4)
|
||||||
|
if n2, _ := c.LikeCount("p1"); n2 != 1 {
|
||||||
|
t.Errorf("LikeCount after duplicate: got %d, want 1 (tx should have been skipped)", n2)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlikeTx := makeTx(blockchain.EventUnlikePost, bob.PubKeyHex(), "",
|
||||||
|
0, blockchain.MinFee, mustJSON(blockchain.UnlikePostPayload{PostID: "p1"}))
|
||||||
|
b5 := buildBlock(t, b4, val, []*blockchain.Transaction{unlikeTx})
|
||||||
|
mustAddBlock(t, c, b5)
|
||||||
|
|
||||||
|
n, _ = c.LikeCount("p1")
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("LikeCount after unlike: got %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeedDeletePostByOther: only the author may delete their post.
|
||||||
|
func TestFeedDeletePostByOther(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
mallory := newIdentity(t) // tries to delete alice's post
|
||||||
|
host := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
const postSize = uint64(100)
|
||||||
|
fee := blockchain.BasePostFee + postSize*blockchain.PostByteFee
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
fee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
fundMallory := makeTx(blockchain.EventTransfer, val.PubKeyHex(), mallory.PubKeyHex(),
|
||||||
|
5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundMallory})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte("body"))
|
||||||
|
postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "", 0, fee,
|
||||||
|
mustJSON(blockchain.CreatePostPayload{
|
||||||
|
PostID: "p1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(),
|
||||||
|
}))
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
// Mallory tries to delete alice's post — block commits, tx is skipped.
|
||||||
|
delTx := makeTx(blockchain.EventDeletePost, mallory.PubKeyHex(), "", 0, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.DeletePostPayload{PostID: "p1"}))
|
||||||
|
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{delTx})
|
||||||
|
mustAddBlock(t, c, b3)
|
||||||
|
rec, _ := c.Post("p1")
|
||||||
|
if rec == nil || rec.Deleted {
|
||||||
|
t.Fatalf("post was deleted by non-author: %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-import lint if fmt ever gets trimmed from the feed tests.
|
||||||
|
var _ = fmt.Sprintf
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ type EventType string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
EventRegisterKey EventType = "REGISTER_KEY"
|
EventRegisterKey EventType = "REGISTER_KEY"
|
||||||
EventCreateChannel EventType = "CREATE_CHANNEL"
|
|
||||||
EventAddMember EventType = "ADD_MEMBER"
|
|
||||||
EventOpenPayChan EventType = "OPEN_PAY_CHAN"
|
EventOpenPayChan EventType = "OPEN_PAY_CHAN"
|
||||||
EventClosePayChan EventType = "CLOSE_PAY_CHAN"
|
EventClosePayChan EventType = "CLOSE_PAY_CHAN"
|
||||||
EventTransfer EventType = "TRANSFER"
|
EventTransfer EventType = "TRANSFER"
|
||||||
@@ -37,6 +35,17 @@ const (
|
|||||||
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
|
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
|
||||||
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
|
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
|
||||||
EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT
|
EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT
|
||||||
|
|
||||||
|
// ── Social feed (v2.0.0) ──────────────────────────────────────────────
|
||||||
|
// Replaces the old channel model with a VK/Twitter-style timeline.
|
||||||
|
// Posts are plaintext, publicly readable, size-priced. Bodies live in
|
||||||
|
// the relay feed-mailbox; on-chain we only keep metadata + author.
|
||||||
|
EventCreatePost EventType = "CREATE_POST" // author publishes a post
|
||||||
|
EventDeletePost EventType = "DELETE_POST" // author soft-deletes their post
|
||||||
|
EventFollow EventType = "FOLLOW" // follow another author's feed
|
||||||
|
EventUnfollow EventType = "UNFOLLOW" // unfollow an author
|
||||||
|
EventLikePost EventType = "LIKE_POST" // like a post
|
||||||
|
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
|
||||||
)
|
)
|
||||||
|
|
||||||
// Token amounts are stored in micro-tokens (µT).
|
// Token amounts are stored in micro-tokens (µT).
|
||||||
@@ -64,6 +73,31 @@ const (
|
|||||||
// MinContactFee is the minimum amount a sender must pay the recipient when
|
// MinContactFee is the minimum amount a sender must pay the recipient when
|
||||||
// submitting an EventContactRequest (anti-spam; goes directly to recipient).
|
// submitting an EventContactRequest (anti-spam; goes directly to recipient).
|
||||||
MinContactFee uint64 = 5_000 // 0.005 T
|
MinContactFee uint64 = 5_000 // 0.005 T
|
||||||
|
|
||||||
|
// ── Feed pricing (v2.0.0) ─────────────────────────────────────────────
|
||||||
|
// A post's on-chain fee is BasePostFee + bytes_on_disk × PostByteFee.
|
||||||
|
// The fee is paid by the author and credited in full to the hosting
|
||||||
|
// relay (the node that received POST /feed/publish and stored the body).
|
||||||
|
// Size-based pricing is what aligns incentives: a 200-byte tweet is
|
||||||
|
// cheap, a 256 KB video costs ~0.26 T — node operators' storage cost
|
||||||
|
// is covered.
|
||||||
|
//
|
||||||
|
// Note: BasePostFee is set to MinFee (1000 µT) because chain-level block
|
||||||
|
// validation requires every tx's Fee ≥ MinFee. So the true minimum a
|
||||||
|
// post can cost is MinFee + size × PostByteFee. A 0-byte post is
|
||||||
|
// rejected (Size must be > 0), so in practice a ~50-byte text post
|
||||||
|
// costs ~1050 µT (~$0.001 depending on token price).
|
||||||
|
BasePostFee uint64 = 1_000 // 0.001 T flat per post — aligned with MinFee floor
|
||||||
|
PostByteFee uint64 = 1 // 1 µT per byte of stored content
|
||||||
|
|
||||||
|
// MaxPostSize caps a single post's on-wire size (text + attachment, post
|
||||||
|
// compression). Hard limit — node refuses larger envelopes to protect
|
||||||
|
// storage and bandwidth.
|
||||||
|
MaxPostSize uint64 = 256 * 1024 // 256 KiB
|
||||||
|
|
||||||
|
// LikeFee / FollowFee / UnlikeFee / UnfollowFee / DeletePostFee all use
|
||||||
|
// MinFee (1000 µT) — standard tx fee paid to the validator. No extra
|
||||||
|
// cost; these events carry no body.
|
||||||
)
|
)
|
||||||
|
|
||||||
// Transaction is the atomic unit recorded in a block.
|
// Transaction is the atomic unit recorded in a block.
|
||||||
@@ -90,11 +124,66 @@ type RegisterKeyPayload struct {
|
|||||||
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
|
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateChannelPayload is embedded in EventCreateChannel transactions.
|
// ── Feed payloads (v2.0.0) ─────────────────────────────────────────────────
|
||||||
type CreateChannelPayload struct {
|
|
||||||
ChannelID string `json:"channel_id"`
|
// CreatePostPayload is embedded in EventCreatePost transactions. The body
|
||||||
Title string `json:"title"`
|
// itself is NOT stored on-chain — it lives in the relay feed-mailbox keyed
|
||||||
IsPublic bool `json:"is_public"`
|
// by PostID. On-chain we only keep author, size, hash, timestamp and any
|
||||||
|
// reply/quote reference for ordering and proof of authorship.
|
||||||
|
//
|
||||||
|
// PostID is computed client-side as hex(sha256(author || content_hash || ts)[:16])
|
||||||
|
// — same scheme as envelope IDs. Clients include it so the relay can store
|
||||||
|
// the body under a stable key before the chain commit lands.
|
||||||
|
//
|
||||||
|
// HostingRelay is the node pubkey (Ed25519 hex) that accepted the POST
|
||||||
|
// /feed/publish call and holds the body. Readers resolve it via the chain
|
||||||
|
// and fetch the body directly from that relay (or via gossipsub replicas).
|
||||||
|
// The fee is credited to this pub.
|
||||||
|
//
|
||||||
|
// QuoteOf / ReplyTo are mutually exclusive; set at most one. ReplyTo makes
|
||||||
|
// the post a reply in a thread; QuoteOf creates a link/reference block.
|
||||||
|
type CreatePostPayload struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
ContentHash []byte `json:"content_hash"` // sha256 of body-bytes, 32 B
|
||||||
|
Size uint64 `json:"size"` // bytes on disk (compressed)
|
||||||
|
HostingRelay string `json:"hosting_relay"` // hex Ed25519 of storing node
|
||||||
|
ReplyTo string `json:"reply_to,omitempty"` // parent post ID
|
||||||
|
QuoteOf string `json:"quote_of,omitempty"` // referenced post ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePostPayload — author soft-deletes their own post. Stored marker
|
||||||
|
// lets clients hide the post; relay can GC the body on the next sweep.
|
||||||
|
type DeletePostPayload struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowPayload / UnfollowPayload — follow graph. tx.From = follower,
|
||||||
|
// tx.To = target. No body.
|
||||||
|
type FollowPayload struct{}
|
||||||
|
type UnfollowPayload struct{}
|
||||||
|
|
||||||
|
// LikePostPayload / UnlikePostPayload — per-post like indicator. tx.From
|
||||||
|
// = liker. The counter is derived on read.
|
||||||
|
type LikePostPayload struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
}
|
||||||
|
type UnlikePostPayload struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostRecord is what we store on-chain under post:<postID>. Consumers of
|
||||||
|
// PostsByAuthor / query endpoints decode this.
|
||||||
|
type PostRecord struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
Author string `json:"author"` // hex Ed25519
|
||||||
|
ContentHash []byte `json:"content_hash"`
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
HostingRelay string `json:"hosting_relay"`
|
||||||
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
|
QuoteOf string `json:"quote_of,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"` // unix seconds (tx timestamp)
|
||||||
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
|
FeeUT uint64 `json:"fee_ut"` // total fee paid
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRelayPayload is embedded in EventRegisterRelay transactions.
|
// RegisterRelayPayload is embedded in EventRegisterRelay transactions.
|
||||||
@@ -241,24 +330,6 @@ type BlockContactPayload struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMember records a participant in a channel together with their
|
|
||||||
// X25519 public key. The key is cached on-chain (written during ADD_MEMBER)
|
|
||||||
// so channel senders don't have to fan out a separate /api/identity lookup
|
|
||||||
// per recipient on every message — they GET /api/channels/:id/members
|
|
||||||
// once and seal N envelopes in a loop.
|
|
||||||
type ChannelMember struct {
|
|
||||||
PubKey string `json:"pub_key"` // Ed25519 hex
|
|
||||||
X25519PubKey string `json:"x25519_pub_key"` // optional; empty if member hasn't registered
|
|
||||||
Address string `json:"address"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddMemberPayload is embedded in EventAddMember transactions.
|
|
||||||
// tx.From adds tx.To as a member of the specified channel.
|
|
||||||
// If tx.To is empty, tx.From is added (self-join for public channels).
|
|
||||||
type AddMemberPayload struct {
|
|
||||||
ChannelID string `json:"channel_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddValidatorPayload is embedded in EventAddValidator transactions.
|
// AddValidatorPayload is embedded in EventAddValidator transactions.
|
||||||
// tx.From must already be a validator; tx.To is the new validator's pub key.
|
// tx.From must already be a validator; tx.To is the new validator's pub key.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -854,8 +854,6 @@ func main() {
|
|||||||
GetNFT: chain.NFT,
|
GetNFT: chain.NFT,
|
||||||
GetNFTs: chain.NFTs,
|
GetNFTs: chain.NFTs,
|
||||||
NFTsByOwner: chain.NFTsByOwner,
|
NFTsByOwner: chain.NFTsByOwner,
|
||||||
GetChannel: chain.Channel,
|
|
||||||
GetChannelMembers: chain.ChannelMembers,
|
|
||||||
Events: sseHub,
|
Events: sseHub,
|
||||||
WS: wsHub,
|
WS: wsHub,
|
||||||
// Onboarding: expose libp2p peers + chain_id so new nodes/clients can
|
// Onboarding: expose libp2p peers + chain_id so new nodes/clients can
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
// Package node — channel endpoints.
|
|
||||||
//
|
|
||||||
// `/api/channels/:id/members` returns every Ed25519 pubkey registered as a
|
|
||||||
// channel member together with their current X25519 pubkey (from the
|
|
||||||
// identity registry). Clients sealing a message to a channel iterate this
|
|
||||||
// list and call relay.Seal once per recipient — that's the "fan-out"
|
|
||||||
// group-messaging model (R1 in the roadmap).
|
|
||||||
//
|
|
||||||
// Why enrich with X25519 here rather than making the client do it?
|
|
||||||
// - One HTTP round trip vs N. At 10+ members the latency difference is
|
|
||||||
// significant over mobile networks.
|
|
||||||
// - The server already holds the identity state; no extra DB hops.
|
|
||||||
// - Clients get a stable, already-joined view — if a member hasn't
|
|
||||||
// published an X25519 key yet, we return them with `x25519_pub_key=""`
|
|
||||||
// so the caller knows to skip or retry later.
|
|
||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go-blockchain/blockchain"
|
|
||||||
"go-blockchain/wallet"
|
|
||||||
)
|
|
||||||
|
|
||||||
func registerChannelAPI(mux *http.ServeMux, q ExplorerQuery) {
|
|
||||||
// GET /api/channels/{id} → channel metadata
|
|
||||||
// GET /api/channels/{id}/members → enriched member list
|
|
||||||
//
|
|
||||||
// One HandleFunc deals with both by sniffing the path suffix.
|
|
||||||
mux.HandleFunc("/api/channels/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
jsonErr(w, fmt.Errorf("method not allowed"), 405)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/channels/")
|
|
||||||
path = strings.Trim(path, "/")
|
|
||||||
if path == "" {
|
|
||||||
jsonErr(w, fmt.Errorf("channel id required"), 400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(path, "/members"):
|
|
||||||
id := strings.TrimSuffix(path, "/members")
|
|
||||||
handleChannelMembers(w, q, id)
|
|
||||||
default:
|
|
||||||
handleChannelInfo(w, q, path)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleChannelInfo(w http.ResponseWriter, q ExplorerQuery, channelID string) {
|
|
||||||
if q.GetChannel == nil {
|
|
||||||
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, err := q.GetChannel(channelID)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, err, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ch == nil {
|
|
||||||
jsonErr(w, fmt.Errorf("channel %s not found", channelID), 404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonOK(w, ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleChannelMembers(w http.ResponseWriter, q ExplorerQuery, channelID string) {
|
|
||||||
if q.GetChannelMembers == nil {
|
|
||||||
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pubs, err := q.GetChannelMembers(channelID)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, err, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := make([]blockchain.ChannelMember, 0, len(pubs))
|
|
||||||
for _, pub := range pubs {
|
|
||||||
member := blockchain.ChannelMember{
|
|
||||||
PubKey: pub,
|
|
||||||
Address: wallet.PubKeyToAddress(pub),
|
|
||||||
}
|
|
||||||
// Best-effort X25519 lookup — skip silently on miss so a member
|
|
||||||
// who hasn't published their identity yet doesn't prevent the
|
|
||||||
// whole list from returning. The sender will just skip them on
|
|
||||||
// fan-out and retry later (after that member does register).
|
|
||||||
if q.IdentityInfo != nil {
|
|
||||||
if info, err := q.IdentityInfo(pub); err == nil && info != nil {
|
|
||||||
member.X25519PubKey = info.X25519Pub
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, member)
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]any{
|
|
||||||
"channel_id": channelID,
|
|
||||||
"count": len(out),
|
|
||||||
"members": out,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -79,12 +79,6 @@ type ExplorerQuery struct {
|
|||||||
GetNFTs func() ([]blockchain.NFTRecord, error)
|
GetNFTs func() ([]blockchain.NFTRecord, error)
|
||||||
NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error)
|
NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error)
|
||||||
|
|
||||||
// Channel group-messaging lookups (R1). GetChannel returns metadata;
|
|
||||||
// GetChannelMembers returns the Ed25519 pubkey of every current member.
|
|
||||||
// Both may be nil on nodes that don't expose channel state (tests).
|
|
||||||
GetChannel func(channelID string) (*blockchain.CreateChannelPayload, error)
|
|
||||||
GetChannelMembers func(channelID string) ([]string, error)
|
|
||||||
|
|
||||||
// Events is the SSE hub for the live event stream. Optional — if nil the
|
// Events is the SSE hub for the live event stream. Optional — if nil the
|
||||||
// /api/events endpoint returns 501 Not Implemented.
|
// /api/events endpoint returns 501 Not Implemented.
|
||||||
Events *SSEHub
|
Events *SSEHub
|
||||||
@@ -127,7 +121,6 @@ func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...Explor
|
|||||||
registerUpdateCheckAPI(mux, q)
|
registerUpdateCheckAPI(mux, q)
|
||||||
registerOnboardingAPI(mux, q)
|
registerOnboardingAPI(mux, q)
|
||||||
registerTokenAPI(mux, q)
|
registerTokenAPI(mux, q)
|
||||||
registerChannelAPI(mux, q)
|
|
||||||
if !f.DisableSwagger {
|
if !f.DisableSwagger {
|
||||||
registerSwaggerRoutes(mux)
|
registerSwaggerRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user