Five contracts: Profile, Posts, Follow, Gallery, Votes. The full source for each is in packages/social-contracts on GitHub — about 350 lines total — but I'll quote the interesting bits inline and explain the design decisions that aren't obvious from reading the code.
The shape of every contract is the same:
({
someMethod(arg) { /* ... */ },
someView() { /* ... */ },
});
That's it. A contract is a JavaScript expression that evaluates to an object. The object's keys are public methods. There's no pragma, no constructor syntax, no separate ABI file. The "ABI" is just JSON: { method: 'someMethod', args: [...] }. The frontend builds that JSON, signs it as part of a tx, the chain dispatches into your contract, you return a value or emit an event. Done.
profile.js — the simplest one
const PREFIX = 'profile:';
const MAX_NAME_LEN = 50;
const MAX_BIO_LEN = 280;
const MAX_AVATAR_LEN = 500;
({
setProfile(name, bio, avatarUrl) {
const sender = String(msg.sender).toLowerCase();
const safeName = String(name == null ? '' : name);
const safeBio = String(bio == null ? '' : bio);
const safeAvatar = String(avatarUrl == null ? '' : avatarUrl);
assert(safeName.length <= MAX_NAME_LEN, 'name too long');
assert(safeBio.length <= MAX_BIO_LEN, 'bio too long');
assert(safeAvatar.length <= MAX_AVATAR_LEN, 'avatarUrl too long');
const existingRaw = storage.get(PREFIX + sender);
const existing = existingRaw ? JSON.parse(existingRaw) : null;
const joinedAt = existing ? existing.joinedAt : String(chain.blockNumber);
const profile = {
address: sender, name: safeName, bio: safeBio, avatar: safeAvatar,
joinedAt: joinedAt, updatedAt: String(chain.blockNumber),
};
storage.set(PREFIX + sender, JSON.stringify(profile));
emit('ProfileUpdated', {
address: sender, name: safeName, isFirst: existing ? '0' : '1',
});
return true;
},
getProfile(address) {
const raw = storage.get(PREFIX + String(address).toLowerCase());
return raw ? JSON.parse(raw) : null;
},
hasProfile(address) {
return storage.has(PREFIX + String(address).toLowerCase());
},
});
A few things worth noticing.
Storage is key-value strings. Not slots. Not a typed map. A literal Map<string, string>. So I just JSON-stringify the profile object and put the whole thing under profile:<addr>. When I read it back I parse the JSON. There's no schema migration if I add a field — old profiles just don't have it, and the frontend renders accordingly.
msg.sender is the connected wallet. Same as Solidity. The chain authenticates the Dilithium3 signature on the tx and exposes the recovered address as msg.sender to the contract. I lowercase it consistently as a defensive measure (addresses come back in lowercase from the chain, but I want to never depend on that in the keys).
Validation is just JavaScript. assert() is a hosted function that throws and aborts the tx if its first arg is falsy. There's no special require syntax — assert is the verb. I cap name at 50, bio at 280 (Twitter classic), avatar URL at 500. Why 500? Cloudinary URLs can get long with all their transforms.
isFirst in the event lets the indexer distinguish "user just joined" from "user updated their profile." I'll come back to this in chapter 4 — it changes the toast wording from "0xabc updated their profile" to "0xabc joined as alice."
The first-set timestamp is preserved. When setProfile is called the second time we read the existing record, copy its joinedAt, and only overwrite updatedAt. Without that, every profile edit would reset the join date and everyone would look like they joined yesterday.
posts.js — monotonic ids, optional images
The posts contract is the heart of the thing. Source on GitHub.
const NEXT_KEY = 'nextPostId';
const POST_PREFIX = 'post:';
const USER_PREFIX = 'userPosts:';
const MAX_CONTENT_LEN = 280;
const MAX_IMAGE_URL_LEN = 500;
({
post(content, imageUrl) {
const sender = String(msg.sender).toLowerCase();
const text = String(content == null ? '' : content);
const img = String(imageUrl == null ? '' : imageUrl);
assert(text.length > 0 || img.length > 0, 'post must have content or an image');
assert(text.length <= MAX_CONTENT_LEN, 'content too long');
assert(img.length <= MAX_IMAGE_URL_LEN, 'imageUrl too long');
const nextRaw = storage.get(NEXT_KEY);
const id = nextRaw ? BigInt(nextRaw) : 1n;
storage.set(NEXT_KEY, String(id + 1n));
const idStr = String(id);
const record = {
id: idStr, author: sender, content: text, imageUrl: img,
ts: String(chain.blockTimestamp),
block: String(chain.blockNumber),
};
storage.set(POST_PREFIX + idStr, JSON.stringify(record));
const userKey = USER_PREFIX + sender;
const userRaw = storage.get(userKey);
const userIds = userRaw ? JSON.parse(userRaw) : [];
userIds.push(idStr);
storage.set(userKey, JSON.stringify(userIds));
emit('Post', {
id: idStr, author: sender,
contentLen: String(text.length),
hasImage: img.length > 0 ? '1' : '0',
});
return idStr;
},
// ...
});
The non-obvious choices:
Monotonic post ids, not hashes. I picked sequential integer ids over content-hash ids deliberately. With monotonic ids the frontend can fetch "the latest 50 posts" with a single getPostRange(latest-49, latest) call. With content hashes you'd need an auxiliary list, and ordering becomes a separate problem. Sequential ids also play nicely with the indexer (no surprises when materializing a feed in chronological order).
A per-user index as a JSON array. userPosts:<addr> is [1, 5, 17, 42] — the post ids that user has authored, in insertion order. Storing the full array on every post is technically O(n) in the user's post count, but in practice for a social network "average user has 50 posts" is way more common than "average user has 500,000 posts." If we hit that scale we paginate the index into chunks. Until then, this is fine and constant-time to read.
Both content and image are optional, but at least one is required. This is the auto-infer post-type rule from the design spec — "if there's an image with no text, it's an image post." The contract doesn't care what kind of post it is; it just enforces the at-least-one rule. The frontend reads the result and picks the layout (text-only card vs image-only card vs caption + image card).
hasImage in the event is a one-character flag that lets the indexer + toast layer say "0xabc just posted an image" without having to fetch the post body. Saves a round-trip on the toast path.
The view methods are straightforward — getPost(id), getPostRange(from, to) (capped at 200 per call), getUserPosts(addr), getUserPostCount(addr). The range cap is there because a single view call gets gas-metered, and 200 reads is a reasonable upper bound for any feed page.
follow.js — forward and reverse indexes
const EDGE_PREFIX = 'follow:';
const FOLLOWING_PREFIX = 'following:';
const FOLLOWERS_PREFIX = 'followers:';
({
follow(target) {
const follower = String(msg.sender).toLowerCase();
const targetAddr = String(target).toLowerCase();
assert(follower !== targetAddr, 'cannot follow yourself');
const edgeKey = EDGE_PREFIX + follower + ':' + targetAddr;
if (storage.has(edgeKey)) return false;
storage.set(edgeKey, '1');
const fwdKey = FOLLOWING_PREFIX + follower;
const fwd = JSON.parse(storage.get(fwdKey) || '[]');
fwd.push(targetAddr);
storage.set(fwdKey, JSON.stringify(fwd));
const revKey = FOLLOWERS_PREFIX + targetAddr;
const rev = JSON.parse(storage.get(revKey) || '[]');
rev.push(follower);
storage.set(revKey, JSON.stringify(rev));
emit('Follow', { follower: follower, target: targetAddr });
return true;
},
// ...
});
Three storage spaces for one logical edge. follow:<a>:<b> is a sentinel for fast isFollowing() checks. following:<addr> is the list of who that address follows. followers:<addr> is the reverse.
The redundancy buys us O(1) reads in both directions. Without the reverse index, "who follows alice?" would require a full scan of every follow:*:alice key — fine on testnet, brutal on a real network. With it, every read is a single JSON parse.
Idempotency is the other thing worth flagging: if you call follow(alice) when you already follow alice, the contract returns false and emits no event. Same for unfollow on a non-existent edge. The frontend doesn't have to remember whether you've already pressed the follow button — it just calls the method, and the chain figures out whether anything actually changed.
gallery.js — same shape, different domain
The gallery contract is structurally a copy of Posts: monotonic ids, per-user index, an event on each addition. The data is just { imageUrl, caption, ts, block } instead of { content, imageUrl, ts, block }.
I kept it as a separate contract instead of folding it into Posts because a gallery is a different surface from a feed. Profile gallery photos aren't ranked; posts are. Profile gallery photos don't have replies; posts do (eventually). Splitting them keeps the model intentions clear — and matches our "no cross-contract calls" rule, which means we can iterate gallery features without touching Posts.
votes.js — toggle semantics that match Reddit
This one's the most fun. Source.
upvote(postId) {
const voter = String(msg.sender).toLowerCase();
const id = String(postId);
const voteKey = VOTE_PREFIX + id + ':' + voter;
const current = storage.get(voteKey);
const score = loadScore(id);
let action;
let direction;
if (current === '+1') {
storage.delete(voteKey);
score.up -= 1;
action = 'cleared';
direction = '0';
} else if (current === '-1') {
storage.set(voteKey, '+1');
score.down -= 1;
score.up += 1;
action = 'flipped';
direction = '+1';
} else {
storage.set(voteKey, '+1');
score.up += 1;
action = 'up';
direction = '+1';
}
saveScore(id, score);
emit('Vote', { postId: id, voter, direction, action, score: String(score.score) });
return action;
},
Three branches:
- You're already upvoting this post → toggle off. Reddit behavior: clicking an active arrow clears it.
- You're currently downvoting it → flip from down to up. Score swings by 2.
- No prior vote → record an upvote. Score goes up by 1.
Mirror logic for downvote(). There's also a clearVote() for explicit removal, mostly for completeness.
The cached {up, down, score} per post means computing the home feed's "Hot" sort doesn't need an aggregation pass — every post already knows its own score. The frontend just reads it via getScores(postIds) (bulk endpoint, capped at 200) and sorts client-side using a Reddit-style hot formula:
function hotScore(post, scoreMap) {
const s = scoreMap[post.id];
const score = s ? Number(s.score) : 0;
const ageHours = Math.max(1, (Date.now() / 1000 - Number(post.ts)) / 3600);
const sign = score > 0 ? 1 : score < 0 ? -1 : 0;
const order = Math.log10(Math.max(Math.abs(score), 1));
return sign * order - HOT_GRAVITY * Math.log10(ageHours);
}
Higher score = higher rank, but score decays with age. Same shape as Reddit's old open-source hot() function from around 2009. With a HOT_GRAVITY of 1.8 a 10-vote post stays on top for about a day before age starts dragging it down.
what's not in any contract
Things I deliberately left out of the on-chain code:
- Replies / threads. Adding a
replyTofield to posts would be a one-line change. I left it for v2 because reply trees imply more design (collapsing, sorting within a thread, etc.) and this is already a lot for one writeup. - Reposts / quotes. Same.
- Direct messages. Encrypted DMs are a real protocol design problem (you need encrypted keys derivable from Dilithium3, threshold encryption, etc.) — out of scope.
- Account deletion. No
deleteProfile()ordeletePost()method. Posts are append-only. We can add a soft-hide flag later if needed. - Spam / rate limiting. The chain itself rate-limits via gas costs. If someone wants to spam, they pay for it. We can add velocity-based throttles in the frontend or indexer if it ever matters.
That's the contract layer. 350-ish lines, 5 files, 48 unit tests, all green.
In Chapter 4 I'll explain why all of that on its own isn't enough for a real social app — and what the indexer does to make the chain feel alive.