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:
vsecoder
2026-04-18 18:36:00 +03:00
parent f2cb5586ca
commit 88848efa63
6 changed files with 695 additions and 168 deletions

View File

@@ -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