diff --git a/blockchain/chain.go b/blockchain/chain.go index 1ce8361..f3f615c 100644 --- a/blockchain/chain.go +++ b/blockchain/chain.go @@ -40,8 +40,14 @@ const ( prefixHeight = "height" // height → uint64 prefixBalance = "balance:" // balance: → uint64 prefixIdentity = "id:" // id: → RegisterKeyPayload JSON - prefixChannel = "chan:" // chan: → CreateChannelPayload JSON - prefixChanMember = "chan-member:" // chan-member:: → "" (presence = member) + + // Social feed (v2.0.0). Replaced the old channel keys (chan:, chan-member:). + prefixPost = "post:" // post: → PostRecord JSON + prefixPostByAuthor = "postbyauthor:" // postbyauthor::: → "" (chrono index) + prefixFollow = "follow:" // follow:: → "" (presence = follows) + prefixFollowInbound = "followin:" // followin:: → "" (reverse index — counts followers) + prefixLike = "like:" // like:: → "" (presence = liked) + prefixLikeCount = "likecount:" // likecount: → uint64 (cached count) prefixWalletBind = "walletbind:" // walletbind: → wallet_pubkey (string) prefixReputation = "rep:" // rep: → RepStats JSON prefixPayChan = "paychan:" // paychan: → PayChanState JSON @@ -538,11 +544,13 @@ func (c *Chain) Identity(pubKeyHex string) (*RegisterKeyPayload, error) { return &p, err } -// Channel returns the CreateChannelPayload for a channel ID, or nil. -func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) { - var p CreateChannelPayload +// ── Feed queries (v2.0.0) ────────────────────────────────────────────────── + +// 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 { - item, err := txn.Get([]byte(prefixChannel + channelID)) + item, err := txn.Get([]byte(prefixPost + postID)) if err != nil { return err } @@ -553,13 +561,62 @@ func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) { if errors.Is(err, badger.ErrKeyNotFound) { 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. -func (c *Chain) ChannelMembers(channelID string) ([]string, error) { - prefix := []byte(fmt.Sprintf("%s%s:", prefixChanMember, channelID)) - var members []string +// PostsByAuthor returns the last `limit` posts by the given author, newest +// first. Iterates `postbyauthor::...` in reverse order. If limit +// ≤ 0, defaults to 50; capped at 200. +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:::" + 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 { opts := badger.DefaultIteratorOptions opts.PrefetchValues = false @@ -568,15 +625,95 @@ func (c *Chain) ChannelMembers(channelID string) ([]string, error) { defer it.Close() for it.Rewind(); it.Valid(); it.Next() { key := string(it.Item().Key()) - // key = "chan-member::" + // key = "follow::" parts := strings.SplitN(key, ":", 3) if len(parts) == 3 { - members = append(members, parts[2]) + out = append(out, parts[2]) } } 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. @@ -741,41 +878,197 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) { return 0, err } - case EventCreateChannel: - var p CreateChannelPayload + // ── Feed events (v2.0.0) ────────────────────────────────────────── + case EventCreatePost: + var p CreatePostPayload 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 { - return 0, fmt.Errorf("CREATE_CHANNEL debit: %w", err) + return 0, fmt.Errorf("CREATE_POST debit: %w", err) } - val, _ := json.Marshal(p) - if err := txn.Set([]byte(prefixChannel+p.ChannelID), val); err != nil { + // Full fee goes to the hosting relay (storage compensation). No + // 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 } - case EventAddMember: - var p AddMemberPayload + case EventDeletePost: + var p DeletePostPayload 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 == "" { - return 0, fmt.Errorf("%w: ADD_MEMBER: channel_id required", ErrTxFailed) + if p.PostID == "" { + 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) { + 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:: + reverse index followin:: + 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: ADD_MEMBER: channel %q not found", ErrTxFailed, p.ChannelID) + 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 } 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 member == "" { - member = tx.From + if err := txn.Delete(lKey); err != nil { + return 0, err } - 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 } @@ -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 // (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) { pubBytes, err := hex.DecodeString(pubKeyHex) if err != nil { diff --git a/blockchain/chain_test.go b/blockchain/chain_test.go index 3a9180c..d58e67b 100644 --- a/blockchain/chain_test.go +++ b/blockchain/chain_test.go @@ -794,3 +794,248 @@ var _ = identity.Generate // Ensure ed25519 and hex are used directly (they may be used via helpers). var _ = ed25519.PublicKey(nil) 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 diff --git a/blockchain/types.go b/blockchain/types.go index 471d413..25f8647 100644 --- a/blockchain/types.go +++ b/blockchain/types.go @@ -11,8 +11,6 @@ type EventType string const ( EventRegisterKey EventType = "REGISTER_KEY" - EventCreateChannel EventType = "CREATE_CHANNEL" - EventAddMember EventType = "ADD_MEMBER" EventOpenPayChan EventType = "OPEN_PAY_CHAN" EventClosePayChan EventType = "CLOSE_PAY_CHAN" EventTransfer EventType = "TRANSFER" @@ -37,6 +35,17 @@ const ( EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership 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). @@ -64,6 +73,31 @@ const ( // MinContactFee is the minimum amount a sender must pay the recipient when // submitting an EventContactRequest (anti-spam; goes directly to recipient). 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. @@ -90,11 +124,66 @@ type RegisterKeyPayload struct { X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging } -// CreateChannelPayload is embedded in EventCreateChannel transactions. -type CreateChannelPayload struct { - ChannelID string `json:"channel_id"` - Title string `json:"title"` - IsPublic bool `json:"is_public"` +// ── Feed payloads (v2.0.0) ───────────────────────────────────────────────── + +// CreatePostPayload is embedded in EventCreatePost transactions. The body +// itself is NOT stored on-chain — it lives in the relay feed-mailbox keyed +// 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:. 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. @@ -241,24 +330,6 @@ type BlockContactPayload struct { 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. // tx.From must already be a validator; tx.To is the new validator's pub key. // diff --git a/cmd/node/main.go b/cmd/node/main.go index f6d9afc..fbaeba6 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -854,8 +854,6 @@ func main() { GetNFT: chain.NFT, GetNFTs: chain.NFTs, NFTsByOwner: chain.NFTsByOwner, - GetChannel: chain.Channel, - GetChannelMembers: chain.ChannelMembers, Events: sseHub, WS: wsHub, // Onboarding: expose libp2p peers + chain_id so new nodes/clients can diff --git a/node/api_channels.go b/node/api_channels.go deleted file mode 100644 index 21cf604..0000000 --- a/node/api_channels.go +++ /dev/null @@ -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, - }) -} diff --git a/node/api_routes.go b/node/api_routes.go index 7564338..5f32321 100644 --- a/node/api_routes.go +++ b/node/api_routes.go @@ -79,12 +79,6 @@ type ExplorerQuery struct { GetNFTs func() ([]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 // /api/events endpoint returns 501 Not Implemented. Events *SSEHub @@ -127,7 +121,6 @@ func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...Explor registerUpdateCheckAPI(mux, q) registerOnboardingAPI(mux, q) registerTokenAPI(mux, q) - registerChannelAPI(mux, q) if !f.DisableSwagger { registerSwaggerRoutes(mux) }