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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user