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

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

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

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

Non-goals

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

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>

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:

  1. replyGate may not be REMOVED (InvalidGate): that value is set only by a removal (§5.6).
  2. If parentId != 0, the parent must exist.
  3. 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.
  4. 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).
  5. 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 exactly 1. 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.
  6. Only then is the ID assigned, the message stored, and Posted emitted.

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).

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

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:

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.

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:

Possible thread-wide controls outside the core:

  1. 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.
  2. 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:

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 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:

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:

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:

  1. sponsoredPost(author, …, sig, proof): check eligibility, consume the author's quota, call board.postBySig(…) (passing through the parent-gate gateData, which is distinct from the pool's own eligibility proof), refund msg.sender for gas at min(tx.gasprice, maxGasPrice) plus a fixed overhead.
  2. 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).
  3. Pools must sponsor the whole by-sig family, not just postssetReplyGateBySig, setAuthorGateBySig, and writes to the standard ListRegistry (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:

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

9. Open questions (decide before core freeze)

  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. Gate discoverability. The Board's checkReply runs the reply check for any gate. Clients distinguish rejection from an unavailable RPC. Clients without proofs send empty gateData. 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.

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).