b(l)o(h)ard — Protocol Design
Status: working draft — reviewed against the implementation, 2026-09-07
Scope: a minimal on-chain messaging protocol for an EVM chain
Proof of concept: blohard.social runs this protocol on Base with the reference client and host from this repository, and its demo blog shows the widget on an ordinary web page
1. Overview
blohard is a bare-bones protocol for public messaging on an EVM chain. Users, identified by their wallets (a key or a smart account), post messages. Other users reply on-chain. Message content (plain text with minimal client-side formatting) lives off-chain. The chain stores only the content's hash, who wrote it, what it replies to, and optional pointers to reply-control rules. Everything is 100% public. Keeping on-chain data private is explicitly not a goal.
The parts
- The core is one immutable contract,
Board, on an EVM chain. It holds the message table and enforces the reply rules. Everything else in this document is optional. - Peripherals are other contracts built around the core: reply gates, list registries, sponsorship pools. The core needs none of them.
- A client is the software a reader or author uses: the blohard web app, the discussion widget that lets any web page carry a thread from the board under its own content, or anything else that speaks to the chain and to hosts.
- A host is an off-chain server with up to two halves. Its content store keeps message bytes by hash. Its gateway indexes the chain's accepted history and answers queries such as feeds, threads and reply counts. A host may run either half or both. A client verifies fetched bytes itself, so it need not trust a store. It trusts its gateway for history and completeness, as it would an RPC provider.
- A sponsor pays gas for someone else's write. Today that is a host's relayer, a service that submits signed posts for accounts on the host's list. Later it can be pools or ERC-4337 paymasters.
- A record is the content bytes a message's hash commits to: one envelope header line, then the body (§3).
Terms of the contract itself, gates, tombstones and the like, are defined in the terminology table below and again where they first appear.
The protocol in one minute
- Per message, the chain holds two storage slots (64 bytes): a keccak256 hash of the content,
the author, a link to the parent message (
0= top-level post), and a timestamp. The record bytes live on any host. Clients fetch them by hash and check the hash themselves, so a content store cannot alter the bytes. The gateway a client chooses remains trusted for chain history and completeness. The core does not prescribe hosting. - A reply is just a message with a parent. Before accepting one, the core asks the parent's effective gate — a swappable, read-only contract that answers "may this account reply?" A gate can express any rule: verified humans only, a friends list, someone else's blocklist.
- Moderation is cheap after a one-time setup. An author points a single account-wide default gate at a rule of their choosing, once. A blocklist is the common case, and with one in place blocking someone across every post is one write, the list entry. Other gates express other rules, such as followers only or verified humans only. A per-message pointer overrides the default where one thread needs a different rule.
- Nothing on-chain can be erased, so deletion is a signal, in three forms. An author can tombstone their own message permanently, the author of a parent can remove a direct reply permanently, and an author can deactivate their whole account reversibly. Clients hide what these mark. A host could also stop serving the content bytes, though the protocol does not ask it to.
- Posting can be sponsored. A user signs a structured message (EIP-712) and a sponsor submits it for them — a host's relayer today, pools of relayers and ERC-4337 paymasters later. Who qualifies (today being on the host's sponsored list, later proofs such as verified humanity) and how much they get is decided by the sponsor, not the core.
- Everything else lives at the edges: names (ENS), follow lists (a reader's own, or an on-chain graph such as the Ethereum Follow Protocol), feeds, tips, quotes and labels are peripheral contracts or client policy, never core features.
A typical lifecycle: Alice posts the hash of her text record, paying gas or using a sponsor, then uploads the bytes to a host. She points her author gate at the shared blocklist gate. Bob replies unless he is on her list. Adding him prevents future replies to messages without an explicit per-message gate. Clients also fold earlier replies covered by that list. Alice can tombstone an old message permanently or deactivate her account reversibly. Humanity checks and composite gates are possible peripherals, not part of the shipped setup.
Design stance
- One immutable, ownerless core contract, deliberately small. No admin keys, no way to upgrade it. That is what makes it safe to build on: peripheral contracts can depend on the core because no one can change the rules under them.
- Everything else composes at the edges. Names and profiles (ENS), moderation, sponsorship, allow/block lists, tips — all are peripheral contracts or client policy, never core features.
- Two separations that do a lot of work:
- Who wrote a message and who paid the gas are separate (
postBySig), which is what makes gas-free posting possible while the network gets started. - Controlling what is written vs. choosing what is shown: the chain controls what gets written into a conversation (reply gates). Clients control what gets shown (filtering). Each covers the other's gaps.
- Who wrote a message and who paid the gas are separate (
Terminology
| term | meaning |
|---|---|
| message | one on-chain record: author, parent link, timestamp, content hash |
| thread | a message plus the reply tree beneath it (reconstructed off-chain) |
| reply gate | a read-only contract that decides who may reply to a specific message (§5) |
| author gate | an author's default gate, covering every message of theirs without a per-message pointer (§5.7) |
| effective gate | whichever gate actually governs a reply: per-message pointer, else author gate, else open (§4.1) |
| sentinel | a reserved gate address the core treats as a signal instead of calling: TOMBSTONE, REMOVED (§4) |
| tombstone | permanent per-message retraction — the message's gate set to TOMBSTONE by its author (§5.6) |
| removal | permanent per-message removal — a reply's gate set to REMOVED by the author of its parent (§5.6) |
| deactivation | reversible account-wide retraction — the author gate set to TOMBSTONE (§5.7) |
| by-sig | the signed variant of a write (EIP-712), which anyone may submit on the signer's behalf — the gas-free path (§4.2) |
| gate data | opaque proof bytes a replier hands to a gate, e.g. proof of being on a list |
| peripheral | any contract built on top of the core (gates, registries, pools). The core needs none of them |
| core | the single immutable Board contract, the only part of the protocol that is not optional (§4) |
| client | software a reader or author uses to talk to the chain and to hosts. The blohard web app is one client |
| host | an off-chain server offering a content store, a gateway, or both (host API) |
| content store | the half of a host that keeps records by hash. Clients verify what they fetch, so it need not be trusted |
| gateway | the half of a host that indexes accepted chain history and answers queries. Trusted for history and completeness, like an RPC provider |
| index | a gateway's copy of accepted chain history, arranged for queries |
| record | the content bytes a message hash commits to: an envelope line, then the body (§3) |
| reference | a link written inside a body: record: to an attachment, post:// to a message, account:// to an account (CONVENTIONS.md §4). Unrelated to "the reference client", which means our own implementation |
| sponsor | whoever pays gas for another account's by-sig write (§6) |
| relayer | a service that submits by-sig writes for signers it chooses to sponsor, for example the accounts on a list (§6) |
| list registry | the shipped ListRegistry peripheral: named allow and block lists anyone maintains, read by gates and by sponsors (§5.3) |
| envelope | the one-line header every content record starts with: version/1 <media-type> (§3) |
| plain key / EOA | a wallet controlled directly by a private key (an "externally owned account"), as opposed to a smart-contract wallet |
2. Goals and non-goals
Goals
- Authorship that cannot be faked, proven by wallet keys (a plain key or a smart-contract wallet).
- An on-chain reply tree whose links are checked (a reply's parent must exist).
- Content that cannot be altered unnoticed, via its hash on-chain. The content itself stays off-chain.
- Other contracts can read the protocol's state and build on it.
- Posting without holding gas: an author signs, and anyone may submit the signature and pay. The core knows no sponsors, so relayers, pools and paymasters need no permission to exist. Who they pay for is their own rule (§6).
- Conversation control: authors decide who may reply, via any contract logic they choose — per message or for their whole account.
Non-goals
- Privacy (all data public).
- Content storage or availability guarantees (integrity only).
- Names, profiles, avatars and follow graphs. Clients can combine ENS, the Ethereum Follow Protocol and other registries. Storing copies on-chain here would duplicate what those already hold, and the copies would go stale.
- A protocol-wide moderator. No account can act on anyone else's messages except where the core gives it that power over its own threads: who may reply to them, and removing a direct reply. Deciding what to show is a job for clients and peripheral contracts.
- Fees or tokens in the core.
3. Data model
A message is:
| field | type | notes |
|---|---|---|
id |
uint64 |
sequential, starting at 1. 0 is reserved to mean "no parent" |
author |
address |
msg.sender, or the author named in a by-sig call once its signature checks out (its own key, or its own ERC-1271 check for a contract account, §4.2) |
parentId |
uint64 |
0 for a top-level post. Otherwise it must reference an existing message |
timestamp |
uint32 |
block timestamp at inclusion (good until 2106) |
contentHash |
bytes32 |
keccak256 of the exact content bytes |
A reply is just a message with nonzero parentId — one code path, one event.
Why a sequential ID, not the content hash: identical content posted by two users (or twice by
one) must not collide. A uint64 is the cheapest thing for replies and peripheral contracts to
store (as a call argument it still fills a 32-byte ABI word), and the counter puts all messages in
one definite order for free. (The alternative —
keccak256(author, nonce, contentHash) — lets a client know the id before the transaction lands,
handy for showing the post immediately, but costs a full 32 bytes everywhere a message is
referenced.)
Storage layout: two 32-byte slots per message — contentHash in one, author (20 bytes) +
parentId (8) + timestamp (4) packed exactly into the other. The reply-gate pointer lives in a
separate table (uint64 => address), so ungated messages (the common case) never pay for it. A
second separate table holds each author's default gate (§5.7) — one slot per author who sets one.
Why minimal storage rather than events only: a design that only emitted events (less
message-storage gas) was considered and rejected. Contracts can only build on stored state —
events (logs) are invisible to other contracts. Checking that a parent exists, reply gates, tips,
and anything else that needs to know who wrote what all require the stored table. Reconsidered and
kept: dropping only the contentHash slot (author, parent and timestamp staying in state) would
save about 22k gas per post and halve the permanent state per message, but every resolution of a
post:// reference would then depend on finding a historical event: either an indexer with an
id-to-block index or a block pointer stored in place of the timestamp. The second slot buys a
concrete property instead: any post's metadata and content commitment come back from one ordinary
contract call, on any node, with no log access. Clients may read posts through a gateway instead,
but the contract getter preserves this independent access for other
integrations.
Content addressing
- The hash is keccak256 over the exact uploaded record bytes, including the envelope header (message bodies use UTF-8 text). Nothing is normalized, on-chain or off — the hash is of the precise bytes. Keccak is the EVM's own hash function, so a future contract could check content on-chain if that were ever needed.
- Content is served at URLs that contain its hash (e.g.,
.../{contentHash}). Clients hash what they fetch and discard anything that does not match, so a content store need not be trusted to preserve bytes and anyone can mirror. The gateway remains trusted for chain metadata and query completeness. The protocol deliberately says nothing about hosting. Registries of per-author mirror hints are a possible later peripheral (§10). - Posting order: transaction first, then upload. An upload names the chain and either the message ID or the transaction that carries it. A host serves bytes only once it has verified that a message commits to their hash. Bytes for a pending transaction may be held for a bounded time, never served, and dropped if the transaction does not land. A host that is only holding bytes says so, and that is not an acknowledgement. An acknowledgement confirms storage, not permanence. Clients keep their own copy until a host acknowledges storage. Availability beyond that is best-effort. Our host's API specifies its admission, retries and durability.
Content format
The full content conventions — media types, the record:, post:// and account:// reference
schemes, attachments, and how a standard client renders and behaves — live in
CONVENTIONS.md. Nothing in them touches the chain. The core of it, stated here
because the on-chain hash commits to it:
Content bytes are a record: one header line, then the body.
version/1 text/plain
<body bytes>
The header ends at the first newline (
0x0A). Everything after it is the body, byte for byte.version/1is the envelope version. A change to the header's own shape increments it. The second token is the record's media type, in the usualtype/subtypeform (lowercase letters, digits,.,-,+, no parameters). Messages aretext/plain. A poll, a profile record, or an interactive embed is simply a new media type: nothing in the contract, the events, or hosting changes — the lesson of AT Protocol's record schemas, and of Farcaster's Frames, which grew a whole interactive layer out of metadata and client behaviour with no change to how messages travel.The on-chain hash covers header and body together, so a record's type is committed to and cannot be swapped underneath its hash.
Readers never render what they cannot identify. Bytes without a valid header, or with a media type the client does not implement, are shown as "unrecognized content", never as text. Hosts refuse to store bytes without a valid header.
Text types are UTF-8. The minimal text-formatting convention and everything else about how bodies are written and read is fixed in CONVENTIONS.md §3. Hosts never format the payload. Readers verify the original record and render its text locally.
A caveat on availability: the chain guarantees the bytes cannot be altered unnoticed, not that they can be found. If every host drops them, the message is a hash pointing at nothing. If findability ever becomes a requirement, the escape hatches — IPFS-style replication, putting the bytes in transaction data or blobs — do not change the on-chain interface. Blobs are pruned after a few weeks, so they buy availability at posting time, not retrieval later. Something must still retain the bytes.
4. Core contract
The contract is named Board, and no protocol string carries the project's name: the signing
domain is ("Board", "1"), quote links use post://, content headers use version/1. The project
can be renamed without touching anything signed, stored or hashed.
This interface describes the next Board deployment. The Board deployed today still takes an
expectedParentGate argument in post and postBySig and in the signed Post type (§5.4); the
README and the host API document that interface until the redeploy.
/// Reserved reply-gate sentinel meaning "retracted by author" (§5.6).
/// PERMANENT as a per-message value: once set, setReplyGate on that message
/// reverts forever. As an author-gate value it instead means account
/// deactivation, which is reversible (§5.7).
/// Never used as a call target: the core rejects replies to a tombstoned
/// message before any STATICCALL. (address(1) is the ecrecover precompile,
/// which is why it must never be called — and never is.)
address constant TOMBSTONE = address(1);
address constant REMOVED = address(2); // set only by the parent's author on a direct reply (§5.6)
interface IBoard {
event Posted(
address indexed author,
uint64 indexed parentId,
address indexed parentAuthor, // author of the message replied to; 0 for a top-level post
uint64 id,
bytes32 contentHash,
address replyGate // per-message pointer attached at post time; 0 = defer to author gate (§5.7)
);
event ReplyGateChanged(uint64 indexed id, address oldGate, address newGate);
event AuthorGateChanged(address indexed author, address oldGate, address newGate);
event NoncesInvalidated(address indexed author, uint256 wordPos, uint256 mask);
// ---- writes ----
function post(
bytes32 contentHash,
uint64 parentId, // 0 = top-level
address replyGate, // gate governing replies to THIS message; 0 = defer to author gate
bytes calldata gateData // opaque extension data; empty when the gate needs none
) external returns (uint64 id);
function postBySig(
address author,
bytes32 contentHash,
uint64 parentId,
address replyGate,
uint256 nonce, // signer-chosen, single-use — see §4.2
uint256 deadline,
bytes calldata sig,
bytes calldata gateData // NOT covered by sig — see §4.2
) external returns (uint64 id);
function setReplyGate(uint64 id, address gate) external; // author only, except REMOVED, which only the parent's author may set; both sentinels are permanent (§5.6)
function setAuthorGate(address gate) external; // msg.sender's default gate (§5.7); 0 clears; TOMBSTONE deactivates (reversible); REMOVED refused
function setReplyGateBySig(address author, uint64 id, address gate, uint256 nonce, uint256 deadline, bytes calldata sig) external;
function setAuthorGateBySig(address author, address gate, uint256 nonce, uint256 deadline, bytes calldata sig) external;
// `author` is the acting signer: the message's author, or for a removal the parent's author (§5.6)
function invalidateNonces(uint256 wordPos, uint256 mask) external; // revoke outstanding signatures (§4.2)
// ---- views ----
function getMessage(uint64 id) external view
returns (address author, uint64 parentId, uint32 timestamp, bytes32 contentHash);
function authorOf(uint64 id) external view returns (address); // the one field gates need; reads the packed slot only
function exists(uint64 id) external view returns (bool);
function replyGate(uint64 id) external view returns (address); // per-message pointer (may be a sentinel)
function authorGate(address author) external view returns (address);
function effectiveGate(uint64 id) external view returns (address); // message → author → open resolution (§4.1); may return TOMBSTONE or REMOVED
function checkReply(uint64 parentId, address replier, bytes calldata gateData)
external view returns (address gate); // the reply check as a view; returns the effective gate or reverts with posting errors
function nextId() external view returns (uint64);
function nonceBitmap(address author, uint256 wordPos) external view returns (uint256);
function DOMAIN_SEPARATOR() external view returns (bytes32);
// ---- errors ----
error ParentNotFound(uint64 parentId); // no such parent (§4.1)
error ParentRetracted(uint64 parentId); // the parent was tombstoned, or its author deactivated (§5.6, §5.7)
error ParentRemoved(uint64 parentId); // the parent was removed by the author of its own parent (§5.6)
error GateRejected(address gate); // false, a revert, or malformed return data (§5.1)
error MessageNotFound(uint64 id);
error NotAuthor(uint64 id, address caller); // only the author changes a message's gate, a removal excepted
error NotParentAuthor(uint64 id, address caller); // only the immediate parent's author removes (§5.6)
error MessageTombstoned(uint64 id); // tombstoned or removed: the gate never changes again (§5.6)
error InvalidGate(address gate); // REMOVED anywhere but a removal (§5.6)
error SignatureExpired(uint256 deadline);
error InvalidSigner(address expected, address recovered); // not the author's signature, by key or by ERC-1271 (§4.2)
error NonceAlreadyUsed(uint256 nonce);
}
The three indexed Posted fields support author, parent and parent-author queries. The reference
host ingests these events once and builds posting lists. Browsers query its domain API rather than
filtering logs. The id is in the event data. Independent callers can read a message by id through
getMessage or find a transaction's event in its receipt. exists cheaply validates a reference.
effectiveGate resolves the current pointer without executing the gate, including retraction,
removal and deactivation.
checkReply(parentId, replier, gateData) runs the reply check through the same internal code as
posting, including the gate's STATICCALL and bounded return-data check. It returns the effective
gate address, or zero if open. A missing, retracted or removed parent and a rejecting gate
produce the same errors as posting. Parent zero means an ungated top-level post. Anyone can query
any prospective replier: this authenticates nobody, creates no message and consumes no nonce. The
result is advisory because state, available gas and transaction context, including nonce state
during postBySig, can differ at inclusion. A direct eth_call to the gate does not itself enforce
STATICCALL, even if its from is the Board. Clients still simulate the complete transaction with
its chosen gas limit before sending.
Note for integrators: the reply graph stores upward edges only (parentId) — contracts cannot
enumerate a message's children. Peripherals that operate on replies (reply bounties, best-answer
payouts) use the claim pattern: the replier presents their own reply ID, and the contract
verifies getMessage(replyId).parentId matches. Enumeration is an indexer concern.
4.1 Posting rules
On every post / postBySig, before assigning an id or storing the message. postBySig first
checks deadline and signature and consumes the nonce. Any subsequent revert rolls that consumption
back:
replyGatemay not beREMOVED(InvalidGate): that value is set only by a removal (§5.6).- If
parentId != 0, the parent must exist. - The effective gate is resolved: the parent's per-message pointer if set, otherwise the parent author's default gate (§5.7). Nothing set at either level resolves to no gate.
- If the effective gate is a sentinel, the reply is rejected with no call made —
TOMBSTONE: the parent was retracted (§5.6) or its author's account is deactivated (§5.7).REMOVED: the parent was removed by the author of its own parent (§5.6). - If an effective gate exists, the core calls
canReply(parentId, author, gateData)with STATICCALL — a read-only call, so the gate cannot change any state. The call must succeed and return at least 32 bytes whose first word is exactly1. Anything else rejects the reply:false, a revert, a short return, any other word, or an address with no code at all (which returns nothing). Bytes after the first word are ignored and never copied, so a gate cannot inflate the replier's memory cost with a huge payload. - Only then is the ID assigned, the message stored, and
Postedemitted.
Top-level posts (parentId == 0) are never gated — spam control for them lives in the
gas/sponsorship layer (§7).
4.2 The by-sig family (EIP-712)
postBySig decouples authorship from gas payment: anyone may submit anyone's signed post, and
attribution stays unforgeable. setReplyGateBySig / setAuthorGateBySig do the same for
moderation, so a sponsor can pay for those writes too. Which writes a sponsor covers is the
sponsor's policy (§6).
- Domain:
("Board", "1", chainId, verifyingContract)— no cross-chain or cross-contract replay. - Typed structs:
Post(address author, bytes32 contentHash, uint64 parentId, address replyGate, uint256 nonce, uint256 deadline), plus parallelSetReplyGate(address author, uint64 id, address gate, uint256 nonce, uint256 deadline)andSetAuthorGate(address author, address gate, uint256 nonce, uint256 deadline). All draw from the same per-author nonce bitmap. - Every struct names the author. Nonces are per author, and one key can stand behind several authors: its own address, and any smart account that accepts its signatures. Without the author in the signed data, one signature over a post was valid for each of those identities in turn, so whoever held it could post the same words as all of them, or apply one deactivation to every account the key controls. With the author inside the struct, a signature is valid for exactly the identity it names. The same bytes submitted under another author fail verification, because that author's struct hashes differently.
- Unordered nonces (a bitmap, as in Uniswap's Permit2). The signer picks any unused
uint256nonce. Consumption is recorded as one bit —nonceBitmap[author][nonce >> 8], bitnonce & 0xff, so 256 nonces share a slot. One author's signatures can land in any order, which matters because posts travel through independent relayers: with sequential nonces, a post stuck at one relayer would stall every later-signed post by the same author, and an expired never-submitted signature would strand them until re-signing. Each signature is valid exactly once.deadlinebounds its lifetime.invalidateNonces(wordPos, mask)gives explicit revocation (unordered nonces have no implicit supersede-by-signing-newer). Per-author inclusion ordering is not guaranteed. Posts need none, since message IDs give a total order. Gate changes do: two outstanding signatures for the same message or author gate can land in either order, and the one signed first can overwrite the one signed last. A signer who changes their mind waits for the first to land, or invalidates its nonce before signing the replacement. - Client allocation of nonces is client policy, not a restriction on the contract's nonce space. Filling one word at a time amortizes the storage. How a client allocates them is its own affair. The README describes our client's scheme.
- What the signature covers:
replyGate, yes — the moderation policy you attach to your own message is author intent.gateData, deliberately no — gate proofs are often fetched at submission time against moving state (e.g., a Merkle proof against a list root that rotates), signing over them would make gasless replies brittle, and a relayer substituting a different-but-valid proof produces the identical outcome. - Any account can be an author, including contract accounts. A signed action names its
author. The core first checks whether the signature recovers to that address (ECDSA). If it does not and the author has code — a smart-contract wallet, or a plain key that delegated its code under EIP-7702 — the core asks the account itself, with ERC-1271'sisValidSignature, whether it stands behind the signature. That is one more STATICCALL, made only for authors with code, with the same discipline as gate calls: all gas forwarded, at most 32 bytes copied back, anything but the exact magic value refused. Plain keys pay nothing extra and are never subject to a call. Why: the core is immutable, so a restriction here could never be lifted. Without 1271, every by-signature path — and so every relayer — would be closed to contract accounts forever. They could post only by paying their own gas or through an ERC-4337 paymaster, and a 7702 account's session keys (scoped, revocable posting keys an app holds) could never sign a sponsored post. With it, delegation, key rotation and recovery all live where they belong, in the account, and the core stays neutral about how an author is controlled. The account layer also carries the key-rotation story: a contract account is a stable author identity with rotatable owner keys — the only mitigation for key compromise, which is otherwise unrecoverable and, on this protocol, hands an attacker irreversible tombstoning. Clients should steer high-value authors there. ENS composes with contract accounts as well as with keys. Unlike Farcaster's signer revocation, revoking a session key never removes the posts it signed, since permanence of the record is a protocol invariant. What the core deliberately does not have is a delegation registry of its own (setDelegate): that would freeze one design of delegation into the immutable contract, where wallets are still evolving theirs. One consequence ofmsg.senderauthorship: a contract that callspostis itself the author, so a helper contract cannot post on a user's behalf. Posting as the user takespostBySig, or a call from the user's own smart account.
5. Reply gates — conversation control
Each message may carry a reply gate: a contract consulted before any direct reply is accepted. This is the anti-troll/anti-bot write control: "only verified humans," "only people on my friends list," "nobody on X's blocklist," or any other contract-expressible predicate.
interface IReplyGate {
/// @param parentId the gated message being replied to
/// @param replier the prospective reply author; authenticated by the core
/// when posting, but not during a `checkReply` preview
/// @param data opaque extension data; empty when the gate needs none
/// @return allowed `true` admits the reply; anything else rejects it
function canReply(uint64 parentId, address replier, bytes calldata data)
external view returns (bool);
}
Gate inputs. The gate receives the parent and the replier, authenticated when posting and merely
prospective during checkReply. Inside the gate, msg.sender is the Board, so the explicit replier
is needed for both direct and sponsored posts. The reply's contentHash remains part of the stored
and signed message but is not supplied to the gate. Content-specific approvals are outside this
interface. A hash supplied inside data is not bound by the Board to the posted content.
The opaque data argument stays as an extension point. The shipped list gates ignore it, and a
client that knows no proofs sends empty bytes. The Board forwards nonempty data too. A future gate
may use it for eligibility proofs without changing the core interface, but obtaining those proofs
still requires client support. This preserves protocol flexibility without requiring every client
to understand arbitrary gates.
5.1 Semantics
- Checked at inclusion time. The protocol invariant: every on-chain reply satisfied its parent's then-current gate at the moment it was posted. The core promises nothing about rule stability (§5.2).
- Direct children only. A gate governs replies to that message, not the whole subtree. See §5.5 for why, and how thread-wide control is recovered.
- Gate resolution: message → author → open. A per-message pointer wins if set, otherwise the
author's default gate (§5.7) applies. A message that should stay open despite the author's default
points at a gate that admits everyone, and closing a thread points at one that admits nobody: no
special cases. (A reserved per-message
OPENsentinel for the first was considered and rejected: a gate contract does the same through the ordinary mechanism, and one fewer reserved value is one fewer branch in resolution and one fewer thing every client and gate author must know.) - View-only, enforced by STATICCALL. This is a core security invariant: the board never executes foreign state-changing code inside anyone's transaction. Foreign calls originating in the Board cannot write state, even if they call another contract. Other contracts, wallets and batchers may perform their own authenticated writes outside those STATICCALLs. (What this does not buy: a view gate can still burn gas or give different answers in simulation vs. inclusion when it reads mutable state — see §8.) Read-only does not limit what a gate can express. A policy that needs state, such as paying to reply or redeeming an invitation, has the replier prepare it in a transaction of their own with a registry or the gate's enrollment function, and the gate then reads that record. A wallet can batch the two steps. Because a view cannot consume anything, such a permission admits any number of replies until it is revoked or expires, so exact per-reply quotas need machinery beyond this pattern. The shipped list gates only read membership.
5.2 Rule changes over time (mutability)
The gate pointer is author-mutable: setReplyGate(id, newGate), author-only (except REMOVED,
§5.6), event-logged. Rationale — moderation is reactive. You discover you need a gate after the
trolls arrive, so fixed-at-post-time rules fail the users who most need protection.
Nailed-down semantics:
- Forward-only, structurally. The gate is checked at reply time and on-chain replies can never be removed, so a rule change cannot invalidate existing replies. Swapping your gate is "new door policy," not "eject people already in the room." This falls out of the design for free. Read-side is another matter: clients re-apply proof-free author blocklist rules to existing replies whose parent has no per-message gate, and fold what those rules now refuse. Unknown gates remain visible (CONVENTIONS.md §6). The author gate is about people and reaches back. Per-message gates are thread rules and never do, so closing a thread leaves its history visible.
- Evented history.
Posted,ReplyGateChangedandAuthorGateChangedidentify the pointers in force at inclusion. Successful posting proves that the Board's checks passed. Reconstructing a gate's full historical decision may also require its own and its dependencies' state. - Three layers of rule change, only the first of which the core sees:
- the pointers — per-message and the author default (§5.7) (core, evented).
- the gate's own config (its storage — the gate spec recommends gates emit their own change events).
- upstream data the gate reads (someone else's blocklist, a humanity registry). If you piggyback on another user's list, they can change your thread's effective rules without you acting — the inherent price of delegation.
- Because of layers 2–3, pointer-immutability would be a weak guarantee anyway. Rule stability is a property an author buys by choosing a verifiably immutable, self-contained gate — something clients can check and badge, not something the protocol promises in general. Such a badge vouches for the gate's behaviour while it stays selected. The pointer itself remains replaceable, so the badge says nothing about what the author selects next.
- A one-way
lockReplyGate(id)(credible rule-permanence for bounty/paid-reply threads) is deliberately omitted. Those use cases live in peripheral contracts that can make their own guarantees, and deletion permanence needs no lock either — tombstones are permanent by rule (§5.6).
5.3 The gate ecosystem (all peripherals — none of this touches the core)
The shipped contracts are ListRegistry, BlocklistGate and
AuthorBlocklistGate. The other examples below are possible extensions, not
implemented contracts or client features.
AlwaysDeny— closes a thread: the message stands, replies stop. Distinct in meaning from the protocol'sTOMBSTONEsentinel (§5.6), which also marks the message itself as retracted — don't tombstone just to close a thread.ListRegistry— anyone maintains named allow/block lists (owner => listId => member => bool). Writes name the owner and revert for any other caller, so a wallet signing from the wrong account fails, in simulation where the wallet simulates first, instead of writing to a list nobody reads. The shippedBlocklistGate(registry, owner, listId)refuses listed repliers. Membership is stored on chain as a set, so a gate answers in one storage read. A graph kept as a log of operations for indexers to replay cannot serve a gate. The host reconstructs membership and list sizes fromAdded/Removedevents and serves them to clients, avoiding a counter write on each change. Pointing your thread at someone else's list is just configuration — the piggyback pattern.- Combinators —
And/Or/Notgates wrapping other gates: "verified human AND not on my blocklist, OR on my friends list." - Author-scoped singletons — because
canReplyreceivesparentId, a gate can resolve the parent's author viaauthorOfand apply per-author config from a registry. One deployed gate thus serves every author at once ("verified human AND not on this message's author's blocklist", resolved at reply time). This is the natural target for an author gate (§5.7). - Not-a-bot — reads a proof-of-humanity registry (e.g., World ID). Proof verification and nullifier registration happen once, out of band, in the user's own transaction with the registry. The gate's check is then a cheap view read.
MirrorGate(ancestorId)— resolves to the ancestor message's current gate. This is opt-in subtree inheritance (§5.5).
5.4 Why there is no pin against gate swaps
A replier previews the parent's gate, sees that it admits them, and sends. While the transaction
waits for inclusion, the parent's author can swap the gate, so the reply executes under a rule the
replier never saw. An earlier draft let the replier pass the gate address they had previewed and
had the core revert if it had changed. It was dropped because it bought little. Gates are read-only,
so a swapped gate can at most reject the reply, admit it under different rules, or burn the gas the
transaction was given, and that last loss is already bounded by the explicit gas limit clients set
and the fixed limit relayers send with. A pin would also have named the gate rather than its rules,
so a gate changing its own list underneath it would not have been caught, and because zero meant
"skip the check", a reply to an open parent could not be pinned at all. Posting checks the gate in
force when it executes, and checkReply remains the way to preview it.
5.5 Why direct-children scope (and how threads stay protected)
The failure mode of unscoped gates: Alice gates her post; Bob (admitted) replies; a bot replies to Bob, whose node is open, and the spam renders inside Alice's thread. Protocol-enforced subtree inheritance was rejected because:
- It gets ownership backwards. Bob's reply is Bob's message — per-message sovereignty is the protocol's model. Subtree enforcement means Alice controls who may talk to Bob, and two policies competing over one edge needs a precedence rule the core shouldn't hardcode.
- It interacts badly with forward-only mutability. When Alice swaps her gate, rules would silently change under authors who never consented. Letting them override makes inheritance toothless, forbidding it subordinates them.
- Cost. Finding the root's gate means walking up the parents (one storage read per level) or a third storage slot per message.
Possible thread-wide controls outside the core:
- Opt-in inheritance: a future client could attach
MirrorGate(ancestorId)to replies with the author's consent. Neither the mirror contract nor that default is implemented. The current client posts with no per-message gate. Bob's node then dynamically follows Alice's current gate — because Bob delegated, not because the protocol forced him. - Read-side curation: whatever leaks into open sub-branches, no client is obliged to render it. Viewers filter subtrees through lists they (or the thread author) trust.
Write gates raise the cost of getting junk on-chain; view filters cap what the junk achieves.
5.6 Tombstones — the deletion signal
On-chain data can never be removed, so "deletion" is a signal — and it reuses the gate field rather
than adding machinery: the author sets their message's gate to the reserved sentinel TOMBSTONE.
The overload is sound because retraction implies closed replies, so the two meanings the field now
carries coincide. (That is also the trick's boundary: an author-set status that should leave replies
open — say, self-flagged-sensitive — cannot be encoded this way and belongs in a peripheral status
registry.) TOMBSTONE is a core-defined constant rather than a by-convention deployed contract so
the signal is unambiguous and identical on every deployment, at the cost of two small special cases:
reject replies to a tombstoned parent, and refuse gate changes on a tombstoned message.
Everything else rides existing machinery for free: authorization is setReplyGate's author-only
check (removal by the parent's author being the one exception, below), the by-sig variant makes
retraction gasless, and ReplyGateChanged already tells every indexer. Existing replies are
untouched (forward-only, as always). How clients show a deleted message and its replies is a
convention (CONVENTIONS.md §6), and hosts may choose to drop the content bytes.
exists(id) stays true. Peripherals detect retraction via replyGate(id) or effectiveGate(id).
The deletion tiers, from softest to hardest:
- Possible host withdrawal: a host can stop serving its copy, and re-uploading can restore availability. Other hosts may still have the bytes, so this cannot guarantee erasure or even unavailability. Offering it is host policy, not a protocol requirement.
- Account deactivation (on-chain, reversible): set the author gate to
TOMBSTONE— one write covering every message without a per-message gate (§5.7). - Removal (on-chain, permanent): the author of the parent sets a direct reply's gate to
REMOVED, with the same permanence as a tombstone, attributed to the parent's author (below). - Tombstone (on-chain, permanent): once a message's gate is
TOMBSTONE,setReplyGateon it reverts forever. A reversible deletion signal can't anchor anything — no reader, counterparty, or workflow can rely on a retraction the author might quietly lift — so per-message deletion is final. Clients should give "delete" confirmation UX befitting an irreversible action. - Permanent account-wide retraction: per-message tombstones (n writes, which an external batcher could submit together. No bulk-delete client is implemented) — deliberately the only irrevocable account-wide form. Appendix A explains why account-level permanence was rejected.
A message posted with replyGate = TOMBSTONE is legal and harmless (a born-retracted post that
clients hide). Two stronger encodings of deletion — clearing the message's storage, and zeroing
contentHash — were examined and rejected. See Appendix A.
Removal by the parent's author. A reply can be ended for good by two people: its author, with
TOMBSTONE, and the author of its immediate parent, with the second sentinel REMOVED. The rule
is deliberately narrow. The parent's author may set REMOVED and nothing else on someone else's
message — no rewriting, no gate of their choosing — and only on a direct reply. A grandparent's
author has no say, consistent with §5.5's decision that thread-wide control is not enforced.
REMOVED is final in exactly the way TOMBSTONE is: replies to the message are refused
(ParentRemoved), and its gate never changes again. The value itself records who acted, since only
the author can set one sentinel and only the parent's author the other, so clients distinguish
moderation from self-retraction with the same state read they already make, and ReplyGateChanged
tells indexers the same. How clients show a removed reply and its branch is a convention
(CONVENTIONS.md §6). Whichever terminal sentinel lands first is final: a tombstoned reply cannot
later be marked removed, nor a removed one tombstoned (MessageTombstoned). REMOVED
is refused everywhere the author would be the one setting it: as an author gate, as a gate at
posting, and on their own message even when it replies to another post of theirs (InvalidGate), so
the value always means someone other than the author acted, and an account cannot dress its own
messages as removed. The by-sig variant makes removal gasless through any relayer. The signer is the
parent's author.
5.7 The author gate — account-wide moderation
The block button — "this user may no longer reply to any of my posts" — is the most-used
moderation primitive in social software, and per-message pointers alone make it one write per
message: an author would have to repoint every existing message (batching setReplyGateBySig lets
all those signatures land in one transaction, but it is still one write per message).
The core therefore keeps one author-level default: authorGate[author], set via setAuthorGate
(author-only, by-sig variant, evented, forward-only like everything else). Reply-time resolution is
message → author → open: a per-message pointer (including TOMBSTONE) always wins, otherwise
the author's default applies. An author who has a default and wants one thread open points that
message at a permissive gate.
What this buys:
- Account-wide blocking in one write. Point your author gate once at an author-scoped policy (§5.3) — e.g., "verified human AND not on my blocklist" — and blocking a user is a single list write, effective at inclusion across every post without a per-message gate, including messages posted before the default was set, since rules are always evaluated at reply time. Forward-only on-chain: existing replies stand, future ones bounce. Read-side, clients re-apply proof-free author-blocklist rules to covered existing replies and fold those the rules now refuse (CONVENTIONS.md §6), so a block also hides the past without any further chain action. The gate receives the parent id, so an author-scoped gate can even carve per-thread exceptions. The shipped list gates do not.
- Two states that are easy to confuse. An unset author gate (zero) is "no default": replies fall through to open, and it is every account's starting state. An author gate that points at an address with no contract is a configured gate that answers nothing, which the core treats as a rejection — fail closed, so a mistyped gate closes replies rather than opening them. Clients apply nothing in either case.
- The cheap end of the storage trade-off. The convention alternative — clients defaulting every message's pointer to an author-scoped gate — costs one storage slot and ~22k gas on every message, increasing its storage from two slots to three. The author default costs one slot per author who sets it, plus one extra storage read (~2.1k gas) on replies to messages with no per-message pointer. That small cost on the common path buys something the per-message model cannot do in a single write.
Account deactivation. At the author level, TOMBSTONE means deactivation: every message
without a per-message pointer resolves to retracted — replies rejected, clients render "account
deactivated" — in one write, announced by one AuthorGateChanged log. Unlike the per-message
tombstone this is reversible: it is an ordinary author-gate value, and lifting it restores every
message without a per-message gate. The two states deliberately mirror the familiar
deactivate/delete pair, and clients distinguish them by which mapping holds the sentinel. (Why a
permanent account-level tombstone was rejected: Appendix A.)
This is the first piece of core state not attached to a message, and the only core addition beyond
postBySig's machinery. It stays policy-free: the core learns nothing about lists or humanity — it
just gains a second place to look up a pointer.
5.8 Quotes are not replies (and are not gated)
A quote/repost is a post whose content references another message via a URI convention
(CONVENTIONS.md §4, post://) — an attachment by reference, usually on a new top-level post but
allowed anywhere a body is. It is deliberately not an on-chain edge and deliberately not subject
to the quoted message's reply gate. Two independent reasons:
- On principle. A reply gate is door policy for the thread attached to your message. A reply occupies a position in your conversation. A quote occupies the quoter's own space — your thread gains no node. Gating quotes would extend author control from "who may write in my room" to "who may discuss me in theirs."
- In practice. The chain sees only a content hash, so a body quote is invisible to the protocol by construction — and even an on-chain quote edge could only gate the machine-readable link, not the act: on a fully public chain anyone can reference a message ID in prose, link it, or screenshot it. A quote gate binds exactly the well-behaved clients and nobody else.
Harassment by quoting is therefore a read-side concern with a read-side answer, consistent with
the write/read duality (§1): an author preference ("no rich embeds of my messages") published in a
peripheral status registry, honored by compliant clients rendering a bare reference instead of an
embedded card — the same enforcement level as Bluesky's quote controls — plus ordinary
blocklist-driven rendering. If machine-readable quote edges are ever wanted (quote counts, indexing
without content parsing), an evented QuoteRegistry peripheral can be added at any time. Nothing
here blocks the core freeze.
The accepted extension in CONVENTIONS.md §4 binds the target's immutable message fields. A quote retains the record the author previewed, so a reorg cannot silently redirect a compliant reader to a different occupant of the same message ID. Readers and indexers require this pin. No legacy quote compatibility path is required. Gateways resolve matching quotes at their accepted depth, like any other indexed fact. This needs no Board change or extra finality wait.
Quotes are also the cross-chain bridge. The URI is chain-qualified, and since threads are chain-local (§9, item 2), a quote is how a post travels between deployments. Three properties make this sound:
Pinned citation. A quote records the immutable commitment described in CONVENTIONS.md §4. The client compares that pin with the target record from its chosen gateway and hashes fetched content before showing it. This detects substitution relative to the saved pin. It does not independently authenticate the gateway's chain view. Copied excerpts are ordinary text, not verified quote cards.
Self-quotes are self-authenticating — the migration story, for keys. The same key signs on every chain, so an author with a plain key who quotes their own posts onto a new deployment provably continues their identity there, with no additional bridge or attestation. Communities migrate by re-anchoring back-catalogs. Old threads never merge with new ones, but authorship and provenance carry with no more trust than the reader already places in its gateway's account of the other chain. A contract account's address proves less: the same address on another chain may hold different code, different owners, or nothing at all, so a contract account continues its identity across chains only where it is deployed under the same control, which the address alone does not show. Clients should treat cross-chain sameness as a property of keys, and of contract accounts only with evidence beyond the address.
Live resolution respects retraction across chains. Clients render embeds by resolving the URI at view time, so a since-tombstoned or deactivated original shows as retracted inside every quote on every chain. Citation-mode rendering should therefore be the default (inline copies freeze the record and bypass the signal) — the read layer extending the author's retraction to every venue that cited them.
No cross-chain replies. A reply lives on its parent's chain, where the parent's gate runs at
inclusion. That is the whole of an author's write-time control, and it is complete only because
replies can come from nowhere else. A cross-chain reply — as a core field naming a remote parent, or
as a convention that treats a post on chain B whose first line references a post on chain A as an
answer to it — would land ungated at write time, whatever clients did afterwards, so neither form
exists in this protocol, and a standard client never presents a post from one chain as a reply
inside a thread on another (CONVENTIONS.md §4). The deeper reason is that everything an author can
do about their thread — gates, deactivation, tombstones — is state on the thread's chain. A reply on
a chain the author has no gas for or no access to would be a conversation attached to their post
that they could neither shape nor leave. Replying across chains is not needed: the same address
posts on every chain and sponsorship covers the gas, so a reply to a post on A is made on A.
Cross-chain remains what this section describes, quoting, which enters nobody's space but the
quoter's. Gates may still use facts from other chains, through proofs carried in gateData (a
Base gate checking Ethereum state, say): that tightens a gate's own check rather than sidestepping
it, and needs no core change.
6. Gasless posting and sponsorship
The deployed service and prospective pool/account-abstraction designs are distinct. Only the bootstrap relayer described first is implemented here.
Gasless posting is the bootstrap plan. The by-sig family (§4.2) is the only core support it needs —
and the ERC-4337 path below needs none at all. Sponsorship itself is peripheral. The first,
centralized form is a relayer: a service that pays for postBySig calls by accounts on a
ListRegistry list of its choosing, within quotas it sets per author and overall, with caps on gas
price and gas per call. Today the eligibility rule is a list. Proof of humanity can replace it
without changing anything else. A relayer can pay for signed moderation too, deletion and removal.
Blocklist edits are ListRegistry writes with no signed variant, so they stay paid, and rule 3
below makes sponsoring them a requirement on a future pool. The permissionless form, where any
relayer is reimbursed by a pool, is the SponsorPool below:
interface IEligibility {
// Stateful is fine HERE (unlike reply gates): the pool is a peripheral the
// user explicitly transacts with, not code the core runs. E.g., World ID
// proof verification + nullifier registration on first use.
function checkEligible(address author, bytes calldata proof) external returns (bool);
}
SponsorPool — funded by anyone, fixed at deploy to an IEligibility gate:
sponsoredPost(author, …, sig, proof): check eligibility, consume the author's quota, callboard.postBySig(…)(passing through the parent-gategateData, which is distinct from the pool's own eligibilityproof), refundmsg.senderfor gas atmin(tx.gasprice, maxGasPrice)plus a fixed overhead.- Anyone deploys pools with whatever gate they like. Sponsors fund pools whose rules they endorse. Relaying is permissionless (any relayer is reimbursed — including the project's own bootstrap relayer).
- Pools must sponsor the whole by-sig family, not just posts —
setReplyGateBySig,setAuthorGateBySig, and writes to the standardListRegistry(which therefore needs its own by-sig entry points). A gasless bootstrap user who can post but cannot block, tombstone, or set an author gate is defenseless in exactly the situations §5 exists for. Pools should whitelist the sponsorable call targets (the board plus known registries) rather than refund arbitrary calls, or refund-draining contracts will pose as registries.
Two hard-won rules:
- Eligibility gates who; quotas gate how much. One verified human must not drain the pool. The sketch above keys quota by author. A per-person bucket needs the eligibility check to return a person key, which the sketch leaves open. World ID's nullifier (its one-per-person identifier) is the ideal quota key: one bucket per human, and humans are hard to fake in bulk.
- Cap refunds (
maxGasPrice+ fixed overhead) or a malicious relayer submits at absurd gas prices and pockets the difference.
Alternative path: ERC-4337 paymasters. The pool becomes an EntryPoint deposit. Eligibility runs
in validatePaymasterUserOp, which must also reserve the quota, since a bundle is validated before
any of its operations execute and several could otherwise pass against the same allowance. postOp
reconciles the charge. ERC-7562 restricts what
validation may read — the workaround is the same registry pattern (verification writes into
paymaster-owned storage keyed by the account. Validation reads it). With EIP-7702 live, plain EOAs
can use paymasters via delegation. On this path the account calls post() directly (the account is
the author). postBySig is not involved, so the two gasless paths coexist: by-sig relaying for any
author, key or contract account (§4.2), and paymasters for smart accounts that prefer 4337. A
first-time user's operation can even carry the code to create their account (initCode), so account
creation and the first post happen in one sponsored operation.
The core is 4337-clean by construction — audit notes, so this stays true: no tx.origin
anywhere (under 4337 it is the bundler — the service that packages user operations — not the user).
No msg.sender == tx.origin / EOA-only guards (contract authors are first-class). Gates run in the
execution phase, so ERC-7562's validation restrictions never touch them. The paymaster never needs
board state during validation. Board bitmap nonces and EntryPoint nonces don't interact. Two
operational wrinkles, not blockers: paymasters pay for execution-reverted ops, so gate-rejected
sponsored replies burn pool deposit — simulate callData before sponsoring and count failures against
quota (relayers face the same issue), and bundler availability on the target chain is an infra
dependency, which is why the funded service relayer leads the bootstrap. Pools and paymasters remain
future options (§10).
Chain-native gas sponsorship is another possible deployment choice. Its eligibility and limits belong to that chain, not this protocol.
7. Spam, floods, and moderation — the defense-in-depth model
| layer | mechanism | governs |
|---|---|---|
| 1 | gas cost | everything on-chain, weak alone on cheap chains |
| 2 | relayer quotas, or future pool quotas | the free tier — the current service limits each account and total sponsored traffic |
| 3 | reply gates | who may write into a conversation (humanity, lists) |
| 4 | reactive blocking — gate swaps and author-gate list writes (§5.7) | flooders: blocked after first abuse, account-wide in one write. Identities that are hard to fake make replacements scarce |
| 5 | read-side curation | what gets rendered, regardless of what landed on-chain — including re-applying the author gate to existing replies (CONVENTIONS.md §6) and the reader's own mute list |
Why per-thread rate limiting is deferred. The bootstrap relayer bounds what it pays for with per-account and global quotas. A funded account can bypass sponsorship, and list membership is not proof of a unique human. Reply gates and client filtering supply separate controls. A future humanity integration could make account replacement harder. A read-only gate cannot consume an allowance. Strict per-thread limits would need a separate design beyond the enrollment pattern (§5.1).
8. Security considerations
- Replay: single-use unordered nonces (bitmap) + deadline + EIP-712 domain (chainId, contract
address) + the author named in every signed struct rule out cross-chain, cross-contract, repeat,
and cross-identity replay (one key behind several accounts, §4.2).
invalidateNoncescovers explicit revocation. - The STATICCALL invariant: the core never executes foreign state-changing code. Its only
external calls are read-only: to a reply gate, and to a contract author's own signature check
(ERC-1271, §4.2). Malicious gates cannot touch board state or pull tokens through their
STATICCALLs. A malicious account can decide only its own signature validity, but either callee can
consume simulation or transaction gas. Residual gate risks for readers, repliers and sponsors
include: gas burn (bounded by the replier's own gas limit. A fixed cap on gas forwarded to gates
was considered and rejected — the EVM has repriced operations before, and fixed allowances break
when it does) and simulation divergence when gates read mutable state (inherent, not fixable by
view). - Gas-burning gates (and gas-burning author accounts on the 1271 path): bounded by the payer, not the core. The core forwards all remaining gas to a gate on purpose: a fixed cap would rule out future gates that legitimately need more, and it would not protect anyone the client does not already protect. A gate cannot burn more than the transaction's gas limit, and a client sets that limit from its own simulation, but simulation is not a promise of the eventual cost: a hostile gate can consume the full limit at inclusion even after a cheap estimate. A client therefore sets an explicit gas limit on every post rather than accepting a wallet default, and a relayer that pays for others sends with a fixed gas limit, refuses posts whose estimate exceeds it, and simulates with exactly the gas limit and price fields it will send, since a gate can read how much gas it was given and behave differently under another limit. Who pays for a hostile gate is not only the replier: a sponsor pays for a failed sponsored attempt, and a gate that is expensive to simulate spends the capacity of whichever host's node runs the preview, so a host caps the gas of every simulation it forwards (ours: cdn/API.md). The residual loss per attempt is that limit times the gas price in execution fees, plus whatever data fee the chain charges per transaction, and a relayer's quotas bound how often it is incurred.
- Reentrancy (a gate calling back into the core mid-transaction): harmless by construction — state is append-only with no deletes, IDs are assigned after all checks, and gates are STATICCALLed anyway.
- A gate changing between preview and inclusion: bounded by STATICCALL (§5.4).
- Data availability: the chain guarantees the bytes cannot be altered unnoticed, not that they can be found (§3).
- Reorgs (blocks being replaced): the protocol assumes none beyond a gateway's configured confirmation depth. Deep-reorg recovery is out of scope for now, deployments target chains where the operator accepts that, and a confirmation count is a deployment policy, not proof of finality. Sequential IDs can shift when blocks are replaced, so clients should wait for finality before ID-dependent actions (tips, gate changes referencing fresh IDs). What our client and host handle is listed once, in the README, with snapshot and cursor rules in the host API.
- Public blocklists leak social signal. Any list enforced on-chain is inspectable — "who blocked whom" is queryable and can itself be weaponized (a dynamic Bluesky documented at scale). This is an accepted consequence of the privacy non-goal, not a fixable flaw. It belongs in user-facing expectations.
9. Open questions (decide before core freeze)
- Edits: retraction is settled (tombstones, §5.6), and tombstone-and-repost covers correction
at the cost of a new ID (existing replies stay under the tombstone — standard social-app
behavior). The remaining call: an author-gated
edit(id, newHash)that preserves the ID and thread position. Trade-off: better correction UX vs. bait-and-switch on repliers (content changing under agreement it gathered — mitigated by evented hash history and "edited" badges, but only for careful readers). An edit would also break every pinned quote of the message and the immutable-record guarantee the rest of this document leans on. Immutable-only is the leaner default. Decide before freeze. - Target chain(s). Same address on every chain is settled: the deploy scripts go through the
deterministic deployer proxy (CREATE2) with fixed salts, so one contract version has one address
everywhere. The protocol designates that deployment, from the canonical deployer and salt, as the
one Board of each chain. A copy of the bytecode from another deployer or salt is not the
protocol's Board. So the chain id alone names the deployment and a
post://<chainId>/<id>reference never needs the address. Each deployment is its own message namespace (the EIP-712 domain binds chainId). Threads are chain-local, by rule: every reply-time check — parent existence, effective-gate resolution, the tombstone check, the gate STATICCALL — is a synchronous read of parent-chain state, and no cross-chain substitute keeps the same meaning (state proofs are stale, so a block on one chain wouldn't bind another promptly — and a gate is code plus its transitive reads, not provable data. Going through a bridge makes replies slow and adds parties to trust. Optimistic schemes need fraud proofs). What crosses chains anyway: identity (same address everywhere, CREATE2 for smart accounts), quotes (post://<chainId>/<id>is chain-qualified, and quotes are ungated §5.8), and read-side feed unification. What doesn't: moderation config binds per deployment (clients batch one signature per chain to sync settings). Consequence: multi-chain deployment fragments conversations into disjoint communities rather than scaling one network — pick one home chain for bootstrap (Farcaster converged on a single purpose-built data chain), and treat additional deployments as deliberate new communities. Revisit only if chains learn to read each other's state promptly. The cost of storage belongs here too: at ~2 slots per message, the state a chain must keep grows in step with the messages that remain, and the right mitigations are chain-level (storage pricing, state expiry/rent, or a chain built for cheap state) rather than protocol-level deletion (§5.6, Appendix A). - Content conventions. Fixed in CONVENTIONS.md: the envelope and
text-formatting rules (§3, Content format), attachments as hash-referenced records, and the
record:/post://reference schemes. Its own open items (size guidance, long histories, read-side labels, mention and quote notifications at scale, the mirror gate) are tracked there. None of them is a contract decision. - Indexing: a gateway builds message, author, child, gate, count, membership and reference indexes from accepted events and verified bodies, so clients issue bounded queries instead of scanning chain history. Personal grouping and filtering remain in the client. How a gateway stores its index and how much it can hold are its own concerns. HOST_INDEX.md defines our host's storage and trust boundary.
- Gate discoverability. The Board's
checkReplyruns the reply check for any gate. Clients distinguish rejection from an unavailable RPC. Clients without proofs send emptygateData. A description or link is still needed to explain how to qualify after rejection. Nonempty proofs require client support or a separate enrollment flow. A type identifier or proof schema alone cannot teach a client how to obtain them. None of this changes the gate interface.
Items 3–5 are ecosystem specifications that can be developed after the core freezes. Only items 1–2 are contract decisions.
10. Deferred — add-anytime peripherals (no freeze pressure)
None of these touch the core. Each can be deployed whenever demand appears. Collected here so §9 stays limited to decisions that must precede the core freeze.
QuoteRegistry— logged, machine-readable quote links, if quote counts/indexing without content parsing are ever wanted (§5.8).- Enrollment registries and authorization gates — verify credentials or record permission to reply before the Board's read-only gate check (§5.1).
- Labeler services — Bluesky-style stackable, subscribable moderation labels. Read-side, so
labelers can run entirely off-chain signing
(reference, label)assertions, the reference being a chain-qualified pinnedpost://link (CONVENTIONS.md §4) so a label cannot land on another chain's message of the same id, with on-chain lists as the special case gates read (§7 layer 5). - Per-author mirror hints — a registry of content-host URL templates per author, steering resolution without ever specifying hosting in the protocol (§3).
- Following — deliberately not a core concept: a gateway serves a feed as a bounded union of
author and parent-author posting lists, and a follow list lives with the reader (CONVENTIONS.md
§6). If a public, composable follow graph is ever wanted, e.g. for "people I follow may reply"
gates, it is an ordinary list in the
ListRegistry(§5.3), not new core state. A client can also take a reader's follows and mutes from an existing on-chain graph such as the Ethereum Follow Protocol. A gate cannot: it needs a list stored as a set it can read in one call, which that graph is not. - List managers — a per-list editor address in the
ListRegistry, so a hot key or a co-moderator can maintain a blocklist without the owner's key. A registry change, not a core one. - ERC-4337 paymaster migration — replacing or supplementing
SponsorPoolrelaying, and also a second gasless path for smart-account authors, alongside by-sig relaying (§6, §4.2).
Appendix A — Alternatives considered and rejected
Collected here so the main sections stay readable. Each of these was examined in depth.
A.1 Clearing a message's storage on delete. Actual slot deletion removes only the current-state
copy: the Posted event, archive-node history, and the off-chain bytes all survive, so it reveals
exactly as much as a tombstone. Meanwhile it retroactively converts the message to event-only under
every peripheral holding a reference (getMessage reverts. Tips, bounties and MirrorGate break)
— defeating the reason storage exists (§3). A tombstone keeps retraction attributable on-chain. A
cleared slot is nearly indistinguishable from a message that never existed. A host can stop serving
its copy, but cannot erase other copies, and no host is required to offer withdrawal. The benefit
would be reclaiming current storage, at the cost of losing state-resolvable records. The design
keeps those records. Storage growth remains a chain-selection concern (§9, item 2).
A.2 Zeroing contentHash as the tombstone encoding (as the indicator itself, or alongside the
gate sentinel for state-neutrality). Three reasons: (1) it breaks the invariant that a message's
content commitment is immutable for its lifetime — zeroing is an edit-to-nothing, and once the hash
field can transition, the crisp split between the immutable record (what was said) and the mutable
status field (write policy / lifecycle) is gone for every client and peripheral; (2) it destroys
EVM-visible provenance (dispute, attestation, and retraction-bounty contracts read the hash of
retracted messages) while erasing nothing real — logs and archive state retain it regardless; (3) as
a standalone indicator its gas cost is backwards: the gate slot is already read on every reply,
making the sentinel check free, whereas the hash slot is not otherwise read at reply time, so using
the hash as the signal adds a storage read to every reply in order to serve the rare delete.
A.3 A permanent account-level tombstone. The author gate is a live default over a living
identity, not a closed object, so enforcing permanence on it leaks at every seam: a
per-message gate would resurrect any individual message (forcing setReplyGate to allow changes in
one direction only), new posts are born-retracted (forcing a policy onto post), and every write
path grows a check. The reversible reading — account deactivation (§5.7) — needs none of that, and
permanence remains available where it is enforceable: per-message tombstones, batched (§5.6).