PR #1 of the multi-device roadmap. Adds per-device X25519 keys registered
on-chain so senders can fan out envelopes across all of a recipient's
physical devices — fixes the single-device limitation where a second
phone / desktop loses messages as soon as the first one reads them.
Chain (blockchain/):
- New event types LINK_DEVICE / UNLINK_DEVICE, signed by the identity's
master Ed25519.
- LinkDevicePayload {x25519_pub_key, device_name} +
UnlinkDevicePayload {x25519_pub_key} on the wire.
- State: prefixDevice + x25519_pub → DeviceRecord{owner, name,
added_at, revoked_at?}; reverse index prefixDevicesByOwner for
O(k) listing. Revoke is a soft-delete — the row stays as a visible
tombstone so offline clients can detect their own revocation and
wipe local state.
- MaxDevicesPerOwner = 10 slot cap; MaxDeviceNameLen = 64.
- Strict lowercase-hex validation on x25519_pub so clients can't
desync on letter case.
- Same-owner re-link is a rename/refresh (recreates reverse index too
— needed after a revoke).
- Chain.DevicesOf(master_pub) returns the active records; empty slice
for legacy identities so senders can fall back to IdentityInfo.X25519Pub.
HTTP (node/):
- GET /api/devices/{master_pub_or_addr} — returns {master_pub, count,
devices[]}. Revoked records filtered out.
- /api/identity/{pub} gains `device_count` so senders can decide
upfront whether to fan out or take the legacy path.
Tests (blockchain/devices_test.go):
- Happy paths (1, 3 devices), foreign-owner rejection, same-owner
refresh after revoke, unlink removes from active set,
foreign-signer unlink rejection, idempotent double-unlink,
malformed pub/name rejection, MaxDevices cap + recovery after
unlink frees a slot, empty list for unknown master.
Also in this commit:
- deploy/single/join.sh — convenience script operators have been
iterating on in this session (joiner-node bring-up + firewall
port patching + Caddy opt-out).
- client-app/app.json — `usesCleartextTraffic: true` on Android so
installed APKs can talk to http:// dev nodes without TLS.
See docs/ROADMAP.md for PRs #2..#4 (client fan-out, pairing flow,
desktop Electron shell).
Server pagination
- blockchain.PostsByAuthor signature extended with beforeTs int64;
passing 0 keeps the previous "everything, newest first" behaviour,
non-zero skips posts with CreatedAt >= beforeTs so clients can
paginate older results.
- node.FeedConfig.PostsByAuthor callback type updated; the two
/feed endpoints that use it (timeline + author) now accept
`?before=<unix_seconds>` and forward it through. /feed/author
limit default dropped from 50 to 30 to match the client's page
size.
- node/api_common.go: new queryInt64 helper for parsing the cursor
param safely (matches the queryInt pattern already used).
Client infinite scroll (Feed tab)
- lib/feed.ts: fetchTimeline / fetchAuthorPosts accept
`{limit?, before?}` options. Old signatures still work for other
callers (fetchForYou / fetchTrending / fetchHashtag) — those are
ranked feeds that don't have a stable cursor so they stay
single-shot.
- feed/index.tsx: tracks loadingMore / exhausted state. onEndReached
(threshold 0.6) fires loadMore() which fetches the next 20 posts
using the oldest currently-loaded post's created_at as `before`.
Deduplicates on post_id before appending. Stops when the server
returns < PAGE_SIZE items. ListFooterComponent shows a small
spinner during paginated fetches.
- FlatList lazy-render tuning on all feed lists (index + hashtag):
initialNumToRender:10, maxToRenderPerBatch:8, windowSize:7,
removeClippedSubviews — first paint stays quick even with 100+
posts loaded.
Chat lazy render
- chats/[id].tsx FlatList: initialNumToRender:25 (~1.5 screens),
maxToRenderPerBatch:12, windowSize:10, removeClippedSubviews.
Keeps initial chat open snappy on conversations with thousands
of messages; RN re-renders a small window around the viewport
and drops the rest.
Tests
- chain_test.go updated for new PostsByAuthor signature.
- All 7 Go packages green.
- tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>