7bfd8c7dea415ef75942e962b6aa396133138f08
20 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7bfd8c7dea |
fix(feed): side padding on action row + stable FAB right-anchor
- Post action row (chat / ❤ / eye / share) had only a small paddingRight and no left padding. First icon sat flush under the avatar and share iron against the card edge. Replaced with paddingHorizontal: 12 so both sides get equal breathing room; each of the four cells still flex:1 so the icons distribute evenly. - FAB kept appearing at the LEFT edge instead of the right on user's device despite position:absolute + right:12. Pressable's dynamic- function style can drop absolute-positioning fields between renders on some RN versions. Wrapping the Pressable in a plain absolute- positioned View fixes this: positioning lives on the View (never re-evaluated mid-render), the Pressable inside only declares size and visuals. pointerEvents="box-none" on the wrapper keeps taps outside the button passing through to the feed list below. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f688f45739 |
fix(feed): breathing room around divider + FAB back to right/bottom corner
- PostSeparator was a bare 1px line flush against both neighbouring cards, so the seam looked like card contact instead of a real break. Wrapped it in a 12px vertical padding — now there's 12px blank above the line, 1px line, 12px blank below. Trimmed the card's own paddingVertical back down since the separator now owns the inter-post breathing room. - FAB was lifted to bottom: max(insets.bottom, 8) + 70 in the previous commit which put it far too high (a leftover from the original experiment with the absoluteFill wrapper). User wants 12px above the NavBar and 12px from the right edge. The Feed screen's container ends at the NavBar top (enforced by (app)/_layout.tsx's outer <View flex:1> + NavBar sibling), so a simple `right: 12, bottom: 12` on position:absolute lands the button exactly there on every device. Removed the now-unnecessary absoluteFill wrapper too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a248c540d5 |
fix(feed): visible post divider + reliable FAB positioning
- Post divider was on each PostCard's outer Pressable as borderBottom (#222), which was barely visible on OLED black and disappeared entirely in pressed state (the pressed bg ate the line). Moved the seam to a dedicated PostSeparator component (1px, #2a2a2a) wired as FlatList's ItemSeparatorComponent on both /feed (timeline / for-you / trending) and /feed/tag/[tag]. Also bumped inter-card vertical padding (14-16 top / 16-20 bottom) so cards have real breathing room even before the divider. - FAB position was flaky: with <Stack> at the (app) level the overlay could end up positioned against the Stack's card view instead of the tab container, which made the button drift around and stick against unexpected edges. Wrapped it in an absoluteFill container with pointerEvents="box-none" — the wrapper owns positioning against the tab screen, the button inside just declares right: 14 / bottom: N. Bumped bottom offset to `max(insets.bottom, 8) + 70` so the FAB always clears the 5-icon NavBar with ~14px visual gap on every device. Shadow switched from blue-cast to standard dark for better depth perception on dark backgrounds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
51bc0a1850 |
fix(feed): card spacing, action-row distribution, tab strip, detail inset
- PostCard rows got cramped paddings and a near-invisible divider. Increased paddingTop 12→16, paddingBottom 12→18, paddingHorizontal 14→16; divider colour #141414→#222222 so the seam between posts is legible on OLED blacks. - Action row (chat / ❤ / view / share) used a fixed gap:32 + spacer. Reworked to four flex:1 cells with justifyContent: space-between, so the first three icons distribute evenly across the row and share pins to the right edge. Matches Twitter's layout where each action occupies a quarter of the row regardless of label width. - Feed tab strip (Подписки / Для вас / В тренде) used flex:1 + gap:10 which bunched the three labels together visually. Switched to justifyContent: space-between + paddingHorizontal:20 so each tab hugs its label and the three labels spread to the edges with full horizontal breathing room. - Post detail screen (/feed/[id]) and hashtag feed (/feed/tag/[tag]) were missing the safe-area top inset — their headers butted right against the status bar / notch. Added useSafeAreaInsets().top as paddingTop on the outer View, matching the rest of the app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
93040a0684 |
fix(client): DM-only info, seed on API error, proper cross-group back stack
Three related UX fixes on the client. 1. Participants count on profile DMs always have exactly two participants (you and the contact) so a "Участников: 1" row was confusing — either it's obviously the other person or it's wrong depending on how you count. Removed for direct conversations; the row still appears for group chats (and shows an em-dash until v2.1.0 gives groups a real member list). 2. Dev feed seed now activates on network / 404 errors The seed was only surfaced when the real API returned an EMPTY array. If the node was down (Network request failed) or the endpoint replied 404, the catch block quietly set posts to [] and the list stayed blank — defeating the point of the seed. Now both the empty- response path AND the network-error path fall back to getDevSeedFeed(), so scrolling / like-toggling works even without a running node. Also made the __DEV__ lookup more defensive: use `globalThis.__DEV__` at runtime instead of the typed global. Some bundler configurations have the TS type but not the runtime binding, or vice-versa — the runtime lookup always agrees with Metro. 3. Back from profile → previous screen instead of tab root Root cause: AnimatedSlot rendered <Slot>, which is stack-less. When /chats/xyz pushed /profile/abc (cross-group), the chats group unmounted. Hitting Back then re-entered chats at its root (/chats list) rather than /chats/xyz. Replaced <Slot> with <Stack> in AnimatedSlot. Tab switching still stays flat because NavBar uses router.replace (which maps to navigation.replace on the Stack — no history accumulation). Cross-group pushes (post author tap from feed, avatar tap from chat header, compose modal) now live in the outer Stack's history, so Back pops correctly to the caller. The nested Stacks (chats/_layout.tsx, feed/_layout.tsx, profile/_layout.tsx) still handle intra-group navigation as before. The PanResponder-based swipe-right-to-back was removed since the native Stack now provides iOS edge-swipe natively; Android uses the system back button. animation: 'none' keeps the visual swap instant — matches the prior Slot look so nothing flashes slide-animations that weren't there before. Sub-group layouts can opt into slide_from_right themselves (profile/_layout.tsx and feed/_layout.tsx already do). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
98a0a4b8ba |
fix(nav): move feed.tsx → feed/index.tsx to unblock duplicate-route error
expo-router error: "A navigator cannot contain multiple 'Screen' components with the same name (found duplicate screen named 'feed')". Cause: having both app/(app)/feed.tsx (a file-route) and app/(app)/feed/ (a folder with _layout.tsx) at the same level makes expo-router try to register two routes called "feed". Fix: the folder's _layout.tsx wraps the tab + its sub-routes in a single Stack, so the tab screen becomes feed/index.tsx — the initial screen of that Stack. Back navigation from /feed/[id] and /feed/tag/[tag] now correctly pops to the Feed tab root. No other route references changed (paths like /(app)/feed, /(app)/feed/<id>, /(app)/feed/tag/<tag> still resolve identically). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5728cfc85a |
fix(client): profile polish + proper back stack + dev feed seed
Profile screen
- Missing safe-area top inset on the header — fixed by wrapping the
outer View in paddingTop: insets.top (matches the rest of the tab
screens).
- "Чат" button icon + text sat on two lines because the button used
column layout by default. Rewritten as a full-width CTA pill with
flexDirection: row and alignItems: center → chat-bubble icon and
label sit on one line.
- Dedicated "Копировать адрес" button removed. The address row in
the info card is now the tap target: pressing it copies to clipboard
and flips the row to "Скопировано" with a green check for 1.8s.
- Posts tab removed entirely. User's right — a "chat profile" has no
posts concept, just participant count + date + encryption. The
profile screen is now a single-view info card (address, encryption
status, added date, participants). Posts are discoverable via the
Feed tab; a dedicated /feed/author/{pub} screen is on the roadmap
for browsing a specific user's timeline.
Back-stack navigation
- app/(app)/profile/_layout.tsx + app/(app)/feed/_layout.tsx added,
each with a native <Stack>. The outer AnimatedSlot is stack-less
(intentional — it animates tab switches), so without these nested
stacks `router.back()` from a profile screen had no history to pop
and fell through to root. Now tapping an author in the feed → back
returns to feed; opening a profile from chat header → back returns
to the chat.
Dev feed seed
- lib/devSeedFeed.ts: 12 synthetic posts (text-only, mix of hashtags,
one with has_attachment, varying likes/views, timestamps from "1m
ago" to "36h ago"). Exposed via getDevSeedFeed() — stripped from
production via __DEV__ gate.
- Feed screen falls back to the dev seed only when the real API
returned zero posts. Gives something to scroll / tap / like-toggle
before the backend has real content; real data takes over as soon
as it arrives.
Note: tapping a mock post into detail will 404 against the real node
(the post_ids don't exist on-chain). Intentional — the seed is for
scroll + interaction feel, not deep linking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0ff2760a11 |
fix(feed): compose footer above keyboard, tab spacing, FAB position
- Composer footer (attach / counter / fee bar) now rides with the keyboard on both platforms: KeyboardAvoidingView behavior is "padding" on iOS and "height" on Android. Previously behavior was undefined on Android, and the global softwareKeyboardLayoutMode:"pan" setting lifted the whole screen — leaving the footer hidden below the keyboard. - Feed tab strip (Подписки / Для вас / В тренде) had zero horizontal breathing room — three equal-width Pressables flush to the edges. Added 12px outer paddingHorizontal + 10px gap + slightly bigger paddingVertical. The active indicator is now 32px wide (was 48) so it sits under the label instead of underlining two words. - Floating compose FAB pinned to the right edge with a 14px inset (was 18). Vertical offset tightened from +70 to +62 above the safe-area inset — closer to the NavBar top edge while still clear of the icons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5b64ef2560 |
feat(client): Twitter-style social feed UI (Phase C of v2.0.0)
Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).
Feed screens
app/(app)/feed.tsx — Feed tab
- Three-way tab strip: Подписки / Для вас / В тренде backed by
/feed/timeline, /feed/foryou, /feed/trending respectively
- Default landing tab is "Для вас" — surfaces discovery without
requiring the user to follow anyone first
- FlatList with pull-to-refresh + viewability-driven view counter
bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
- Floating blue compose button → /compose
- Per-post liked_by_me fetched in batches of 6 after list load
app/(app)/compose.tsx — post composer modal
- Fullscreen, Twitter-like header (✕ left, Опубликовать right)
- Auto-focused multiline TextInput, 4000 char cap
- Hashtag preview chips that auto-update as you type
- expo-image-picker + expo-image-manipulator pipeline: resize to
1080px max-dim, JPEG Q=50 (client-side first-pass compression
before the mandatory server-side scrub)
- Live fee estimate + balance guard with a confirmation modal
("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
- Exif: false passed to ImagePicker as an extra privacy layer
app/(app)/feed/[id].tsx — post detail
- Full PostCard rendering + detailed info panel (views, likes,
size, fee, hosting relay, hashtags as tappable chips)
- Triggers bumpView on mount
- 410 (on-chain soft-delete) routes back to the feed
app/(app)/feed/tag/[tag].tsx — hashtag feed
app/(app)/profile/[address].tsx — rebuilt
- Twitter-ish profile: avatar, name, address short-form, post count
- Posts | Инфо tab strip
- Follow / Unfollow button for non-self profiles (optimistic UI)
- Edit button on self profile → settings
- Secondary actions (chat, copy address) when viewing a known contact
Supporting library
lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
- publishPost (POST /feed/publish, signed)
- publishAndCommit (publish → on-chain CREATE_POST)
- fetchPost / fetchStats / bumpView
- fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
fetchHashtag
- buildCreatePostTx / buildDeletePostTx
- buildFollowTx / buildUnfollowTx
- buildLikePostTx / buildUnlikePostTx
- likePost / unlikePost / followUser / unfollowUser / deletePost
(high-level helpers that bundle build + submitTx)
- formatFee, formatRelativeTime, formatCount — Twitter-like display
helpers
components/feed/PostCard.tsx — core card component
- Memoised for performance (N-row re-render on every like elsewhere
would cost a lot otherwise)
- Optimistic like toggle with heart-bounce spring animation
- Hashtag highlighting in body text (tappable → hashtag feed)
- Long-press context menu (Delete, owner-only)
- Views / likes / share-link / reply icons in footer row
Navigation cleanup
- NavBar: removed the SOON pill on the Feed tab (it's shipped now)
- (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
- AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
sub-routes so back-swipe-right closes them
Channel removal (client side)
- lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
'channel' flag removed. `kind` field kept for backward compat with
existing AsyncStorage records.
- lib/devSeed.ts: dropped the 5 channel seed contacts.
- components/ChatTile.tsx: removed channel kindIcon branch.
Dependencies
- expo-image-manipulator added for client-side image compression.
- expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
that API to the legacy sub-path; the new streaming API isn't yet
stable).
Type check
- npx tsc --noEmit — clean, 0 errors.
Next (not in this commit)
- Direct attachment-bytes endpoint on the server so post-detail can
actually render the image (currently shows placeholder with URL)
- Cross-relay body fetch via /api/relays + hosting_relay pubkey
- Mentions (@username) with notifications
- Full-text search
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9e86c93fda |
test(feed): end-to-end integration + two-node propagation (Phase B hardening)
Adds two integration-test files that exercise the full feed stack over
real HTTP requests, plus a fix to the publish signature model that the
EXIF scrubbing test surfaced.
Bug fix — api_feed.go publish signature flow
Previously: server scrubbed the attachment → computed content_hash
over the SCRUBBED bytes → verified the author's signature against
that hash. But the client, not owning the scrubber, signs over the
RAW upload. The two hashes differ whenever scrub touches the bytes
(which it always does for images), so every signed upload with an
image was rejected as "signature invalid".
Fixed order:
1. decode attachment from base64
2. compute raw_content_hash over Content + raw attachment
3. verify author's signature against raw_content_hash
4. scrub attachment (strips EXIF / re-encodes)
5. compute final_content_hash over Content + scrubbed attachment
6. return final hash in response for the on-chain CREATE_POST tx
The signature proves the upload is authentic; the final hash binds
the on-chain record to what readers actually download.
node/feed_e2e_test.go
In-process harness: real BadgerDB chain + feed mailbox + media
scrubber + httptest.Server with RegisterFeedRoutes. Tests drive
it via real http.Post / http.Get so rate limiters, auth, scrubber,
and handler code all run on the happy path.
Tests:
- TestE2EFullFlow — publish → CREATE_POST tx → body fetch → view
bump → stats → author list → soft-delete → 410 Gone on re-fetch
- TestE2ELikeUnlikeAffectsStats — on-chain LIKE_POST bumps /stats,
liked_by_me reflects the caller
- TestE2ETimeline — follow graph, merged timeline newest-first
- TestE2ETrendingRanking — likes × 3 + views puts hot post at [0]
- TestE2EForYouFilters — excludes own posts + followed authors +
already-liked posts; surfaces strangers
- TestE2EHashtagSearch — tag returns only tagged posts
- TestE2EScrubberStripsEXIF — injects SUPERSECRETGPS canary into a
JPEG APP1 segment, uploads via /feed/publish, reads back — asserts
canary is GONE from stored attachment. This is the privacy-critical
regression gate: if it ever breaks, GPS coordinates leak.
- TestE2ERejectsMIMEMismatch — PNG labelled as JPEG → 400
- TestE2ERejectsBadSignature — wrong signer → 403
- TestE2ERejectsStaleTimestamp — 1-hour-old ts → 400 (anti-replay)
node/feed_twonode_test.go
Simulates two independent nodes sharing block history (gossip via
same-block AddBlock on both chains). Verifies the v2.0.0 design
contract: chain state replicates, but post BODIES live only on the
hosting relay.
Tests:
- TestTwoNodePostPropagation — Alice publishes on A; B's chain sees
the record; B's HTTP /feed/post/{id} returns 404 (body is A's);
fetch from A succeeds using hosting_relay field from B's chain
lookup. Documents the client-side routing contract.
- TestTwoNodeLikeCounterSharedAcrossNodes — Bob likes from Node B;
both A's and B's /stats show likes=1. Proves engagement aggregates
are chain-authoritative, not per-relay.
- TestTwoNodeFollowGraphReplicates — FOLLOW tx propagates, /timeline
on B returns A-hosted posts with metadata (no body, as designed).
Coverage summary
Publish flow (sign → scrub → hash → store): ✓
CREATE_POST on-chain fee accounting: ✓
Like / Unlike counter consistency: ✓
Follow graph → timeline merge: ✓
Trending ranking by likes × 3 + views: ✓
For You exclusion rules (self, followed, liked): ✓
Hashtag inverted index: ✓
View counter increment + stats aggregate: ✓
Soft-delete → 410 Gone: ✓
Metadata scrubbing (EXIF canary): ✓
MIME mismatch rejection: ✓
Signature authenticity: ✓
Timestamp anti-replay (±5 min window): ✓
Two-node block propagation: ✓
Cross-node body fetch via hosting_relay: ✓
Likes aggregation across nodes: ✓
All 7 test packages green: blockchain consensus identity media node
relay vm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
f885264d23 |
feat(media): mandatory metadata scrubbing on /feed/publish + FFmpeg sidecar
Every photo from a phone camera ships with an EXIF block that leaks:
GPS coordinates, camera model + serial, original timestamp, software
name, author/copyright fields, sometimes an embedded thumbnail that
survives cropping. For a social feed positioned as privacy-friendly
we can't trust the client alone to scrub — a compromised build,
a future plugin, or a hostile fork would simply skip the step and
leak authorship data.
So: server-side scrub is mandatory for every /feed/publish upload.
New package: media
media/scrub.go
- Scrubber type with Scrub(ctx, bytes, claimedMIME) → (clean, actualMIME)
- ScrubImage handles JPEG/PNG/GIF/WebP in-process: decodes, optionally
downscales to 1080px max-dim, re-encodes as JPEG Q=75. Stdlib
jpeg.Encode emits ZERO metadata → scrub is complete by construction.
- Sidecar client (HTTP): posts video/audio bytes to an external
FFmpeg worker at DCHAIN_MEDIA_SIDECAR_URL
- Magic-byte MIME detection: rejects uploads where declared MIME
doesn't match actual bytes (prevents a PDF dressed as image/jpeg
from bypassing the scrubber)
- ErrSidecarUnavailable: explicit error when video arrives but no
sidecar is wired; operator opts in to fallback via
--allow-unscrubbed-video (default: reject)
media/scrub_test.go
- Crafted EXIF segment with "SECRETGPS-…Canon-EOS-R5" canary —
verifies the string is gone after ScrubImage
- Downscale test (2000×1000 → 1080×540, aspect preserved)
- MIME-mismatch rejection
- Magic-byte detector sanity table
FFmpeg sidecar — new docker/media-sidecar/
Tiny Go HTTP service (~180 LOC, no non-stdlib deps) that shells out
to ffmpeg with -map_metadata -1 + -map 0:v -map 0:a? to guarantee
only video + audio streams survive (no subtitles, attached pictures,
or data channels that could carry hidden info).
Re-encode profile:
video → H.264 CRF 28 preset=fast, Opus 64k, MP4 faststart
audio → Opus 64k, Ogg container
Dockerfile: two-stage build (Go → alpine+ffmpeg), ~90 MB image, non-
root user, /healthz endpoint for compose probes.
Node reaches it via DCHAIN_MEDIA_SIDECAR_URL. Without it, video uploads
are rejected with 503 unless operator sets DCHAIN_ALLOW_UNSCRUBBED_VIDEO.
/feed/publish wiring
- cfg.Scrubber is a required dependency
- Before storing post body we call scrubber.Scrub(); attachment bytes
+ MIME are replaced with the cleaned version
- content_hash is computed over the SCRUBBED bytes — so the on-chain
CREATE_POST tx references exactly what readers will fetch
- EstimatedFeeUT uses the scrubbed size, so author's fee reflects
actual on-disk cost
- Content-type mismatches → 400; sidecar unavailable for video → 503
Flags / env vars
--feed-db / DCHAIN_FEED_DB (existing)
--feed-ttl-days / DCHAIN_FEED_TTL_DAYS (existing)
--media-sidecar-url / DCHAIN_MEDIA_SIDECAR_URL (NEW)
--allow-unscrubbed-video / DCHAIN_ALLOW_UNSCRUBBED_VIDEO (NEW; default false)
Client responsibilities (for reference — client work lands in Phase C)
Even with server-side scrub, the client should still compress aggressively
BEFORE upload, because:
- upload time is ~N× larger for unscrubbed media (mobile networks)
- the server's 256 KiB MaxPostSize is a HARD cap — oversized uploads
are rejected, not silently truncated
- the on-chain fee is size-based, so users pay for every byte the
client didn't bother to shrink
Recommended client pipeline:
images → expo-image-manipulator: resize max-dim 1080px, WebP or
JPEG quality 50-60
videos → react-native-compressor: H.264 CRF 28, 720p max, 64k audio
audio → expo-audio's default Opus 32k (already compressed)
Documented in docs/media-sidecar.md (added later with Phase C PR).
Tests
- go test ./... green across 6 packages (blockchain consensus identity
media relay vm)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
126658f294 |
feat(feed): relay body storage + HTTP endpoints (Phase B of v2.0.0)
Phase A (the previous commit) added the on-chain foundations. Phase B
is the off-chain layer: post bodies live in a BadgerDB-backed feed
mailbox, and a full HTTP surface makes the feed usable from clients.
New components
relay/feed_mailbox.go (+ tests)
- FeedPost: body + content-type + attachment + hashtags + thread refs
- Store / Get / Delete with TTL-bounded eviction (30 days default)
- View counter (IncrementView / ViewCount) — off-chain because one
tx per view would be nonsense
- Hashtag inverted index: auto-extracts #tokens from content on
Store, lowercased + deduped + capped at 8/post
- Author chrono index: PostsByAuthor returns newest-first IDs
- RecentPostIDs: scan-by-age helper used by trending/foryou
node/api_feed.go
POST /feed/publish — author-signed body upload, returns
post_id + content_hash + size +
hashtags + estimated fee for the
follow-up on-chain CREATE_POST tx
GET /feed/post/{id} — fetch body (respects on-chain soft
delete, returns 410 when deleted)
GET /feed/post/{id}/stats — {views, likes, liked_by_me?}
POST /feed/post/{id}/view — bump the counter
GET /feed/author/{pub} — chain-authoritative post list
enriched with body + stats
GET /feed/timeline — merged feed from people the user
follows (reads chain.Following,
fetches each author's recent posts)
GET /feed/trending — top-scored posts in last 24h
(score = likes × 3 + views)
GET /feed/foryou — simple recommendations: recent posts
minus authors the user already
follows, already-liked posts, and
own posts; ranked by engagement
GET /feed/hashtag/{tag} — posts tagged with the given #tag
cmd/node/main.go wiring
- --feed-db flag (DCHAIN_FEED_DB) + --feed-ttl-days (DCHAIN_FEED_TTL_DAYS)
- Opens FeedMailbox + registers FeedRoutes alongside RelayRoutes
- Threads chain.Post / LikeCount / HasLiked / PostsByAuthor / Following
into FeedConfig so HTTP handlers can merge on-chain metadata with
off-chain body+stats.
Auth & safety
- POST /feed/publish: Ed25519 signature over "publish:<post_id>:
<content_sha256_hex>:<ts>"; ±5-minute skew window for anti-replay.
- content_hash binds body to the on-chain tx — you can't publish
body-A off-chain and commit hash-of-body-B on-chain.
- Writes wrapped in withSubmitTxGuards (rate-limit + size cap), reads
in withReadLimit — same guards as /relay.
Trending / recommendations
- V1 heuristic (likes × 3 + views) + time window. Documented as
v2.2.0 "Feed algorithm" candidate for a proper ranking layer
(half-life decay, follow-of-follow boost, hashtag collaborative).
Tests
- Store round-trip, size enforcement, hashtag indexing (case-insensitive
+ dedup), view counter increments, author chrono order, delete
cleans all indices, RecentPostIDs time-window filter.
- Full go test ./... is green (blockchain + consensus + identity +
relay + vm all pass).
Next (Phase C): client Feed tab — composer, timeline, post detail,
profile follow, For You + Trending screens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
88848efa63 |
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>
|
||
|
|
f2cb5586ca |
fix(relay): require signed Ed25519 auth on DELETE /relay/inbox/{id}
Previously the endpoint accepted an unauthenticated DELETE with just
?pub=X — anyone who knew (or enumerated) a pub could wipe that pub's
entire inbox, a trivial griefing vector. Now the handler requires a
JSON body with {ed25519_pub, sig, ts} where sig signs
"inbox-delete:<envID>:<pub>:<ts>" under the Ed25519 privkey. The
server then looks up the identity on-chain and verifies that the
registered X25519 public key matches the ?pub= query — closing the
gap between "I can sign" and "my identity owns this mailbox."
Timestamp window: ±300s to prevent replay of captured DELETEs.
Wires RelayConfig.ResolveX25519 via chain.Identity() in cmd/node/main.go.
When ResolveX25519 is nil the endpoint returns 503 (feature unavailable)
rather than silently allowing anonymous deletes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.0.2
|
||
|
|
15d0ed306b |
fix(ws): hard-deny inbox:* / typing:* when authX is empty
The WS topic-auth check had a soft-fail fallback: if the authenticated identity had no registered X25519 public key (authX == ""), the topic-ownership check was skipped and the client could subscribe to any inbox:* or typing:* topic. Exploit: register an Ed25519 identity without an X25519 key, subscribe to the victim's inbox topic, receive their envelope notifications. Now both topics hard-require a registered X25519. Clients must call REGISTER_KEY (publishing X25519) before subscribing. The scope is narrow — only identities that haven't completed REGISTER_KEY yet could have exploited this — but a hard fail is still correct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8082dd0bf7 |
fix(node): rate-limit relay HTTP endpoints
Relay routes were not wrapped in any guards — /relay/broadcast accepted unlimited writes from any IP, and /relay/inbox could be scraped at line rate. Combined with the per-recipient FIFO eviction (MailboxPerRecipientCap=500), an unauthenticated attacker could wipe a victim's real messages by spamming 500 garbage envelopes. This commit wraps writes in withSubmitTxGuards (10/s per IP + 256 KiB body cap) and reads in withReadLimit (20/s per IP) — the same limits already used for /api/tx and /api/address. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
32eec62ba4 |
fix(chain): RELAY_PROOF dedup by envelopeID + sticky BlockContact
RELAY_PROOF previously had no per-envelope dedup — every relay that saw the gossipsub re-broadcast could extract the sender's FeeSig from the envelope and submit its own RELAY_PROOF claim with its own RelayPubKey. The tx-ID uniqueness check didn't help because tx.ID = sha256(relayPubKey||envelopeID)[:16], which is unique per (relay, envelope) pair. A malicious mesh of N relays could drain N× the fee from the sender's balance for a single message. Fix: record prefixRelayProof:<envelopeID> on first successful apply and reject subsequent claims for the same envelope. CONTACT_REQUEST previously overwrote any prior record (including a blocked one) back to pending, letting spammers unblock themselves by paying another MinContactFee. Now the handler reads the existing record first and rejects the tx with "recipient has blocked sender" when prev.Status == ContactBlocked. Block becomes sticky. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
78d97281f0 |
fix(relay): canonicalise envelope ID and timestamp on mailbox.Store
The mailbox previously trusted the client-supplied envelope ID and SentAt,
which enabled two attacks:
- replay via re-broadcast: a malicious relay could resubmit the same
ciphertext under multiple IDs, causing the recipient to receive the
same plaintext repeatedly;
- timestamp spoofing: senders could back-date or future-date messages
to bypass the 7-day TTL or fake chronology.
Store() now recomputes env.ID as hex(sha256(nonce||ct)[:16]) and
overwrites env.SentAt with time.Now().Unix(). Both values are mutated
on the envelope pointer so downstream gossipsub publishes agree on the
normalised form.
Also documents /relay/send as non-E2E — the endpoint seals with the
relay's own key, which breaks end-to-end authenticity. Clients wanting
real E2E should POST /relay/broadcast with a pre-sealed envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.0.1
|
||
|
|
546d2c503f |
chore(release): clean up repo for v0.0.1 release
Excluded from release bundle:
- CONTEXT.md, CHANGELOG.md (agent/project working notes)
- client-app/ (React Native messenger — tracked separately)
- contracts/hello_go/ (unused standalone example)
Kept contracts/counter/ and contracts/name_registry/ as vm-test fixtures
(referenced by vm/vm_test.go; NOT production contracts).
Docs refactor:
- docs/README.md — new top-level index with cross-references
- docs/quickstart.md — rewrite around single-node as primary path
- docs/node/README.md — full rewrite, all CLI flags, schema table
- docs/api/README.md — add /api/well-known-version, /api/update-check
- docs/contracts/README.md — split native (Go) vs WASM (user-deployable)
- docs/update-system.md — new, full 5-layer update system design
- README.md — link into docs/, drop CHANGELOG/client-app references
Build-time version system (inherited from earlier commits this branch):
- node --version / client --version with ldflags-injected metadata
- /api/well-known-version with {build, protocol_version, features[]}
- Peer-version gossip on dchain/version/v1
- /api/update-check against Gitea release API
- deploy/single/update.sh with semver guard + 15-min systemd jitter
v0.0.1
|
||
|
|
7e7393e4f8 |
chore: initial commit for v0.0.1
DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract |