blohard protocol

This is the specification of blohard, a message board whose posts live on chain. It covers two layers that every client and host must agree on:

layer what it is where
chain protocol the Board contract: messages, replies, reply gates, deletion, signed actions §3–§5, §8, §9
content the bytes a message's hash commits to: records, text, references, attachments §6, §7

A third layer, how a client talks to a particular host (uploads, queries, limits), is each host's own business and is documented by that host.

No protocol string carries the project's name. The contract is Board, its signing domain is ("Board", "1"), quote links use post://, and content headers use version/1, so the project can be renamed without touching anything signed, stored or hashed.

1. Overview

The Board is immutable and ownerless: no admin, no upgrade path, no fees.

2. Terms

3. Messages

field type notes
id uint64 sequential, starting at 1. 0 means "no parent"
author address the caller, or the author named in a signed write once its signature checks out (§4.2)
parentId uint64 0 for a post, otherwise an existing message
timestamp uint32 the block timestamp at inclusion (good until 2106)
contentHash bytes32 keccak-256 of the exact record bytes (§6)

Each message takes two storage slots: the content hash, and the author, parent and timestamp packed into one word. A message's reply gate lives in a separate table, so messages without one never pay for it. A third table holds each author's default gate.

Messages are stored, not only logged, so other contracts can build on them: check that a message exists, read its author, or verify that a reply answers a given message (the reply graph stores upward links only, so contracts verify a reply someone presents rather than enumerating children). Any message's fields come back from one ordinary contract call, on any node, without log access.

Events

event Posted(address indexed author, uint64 indexed parentId, address indexed parentAuthor,
             uint64 id, bytes32 contentHash, address replyGate);
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);

parentAuthor is the author of the message replied to, or zero for a post. replyGate is the gate set at posting, or zero to use the author's default. The indexed fields support author, parent and parent-author queries, which is what a gateway needs to build threads and notifications.

4. The Board

The full interface, with every error, is in src/IBoard.sol. In brief:

function what it does
post(contentHash, parentId, replyGate, gateData) post as the caller
postBySig(author, contentHash, parentId, replyGate, nonce, deadline, sig, gateData) post as a signer (§4.2)
setReplyGate(id, gate) and setReplyGateBySig(...) change a message's gate, delete it, or remove it (§5.2, §5.6)
setAuthorGate(gate) and setAuthorGateBySig(...) set the author's default gate, or deactivate the account (§5.7)
invalidateNonces(wordPos, mask) revoke outstanding signatures (§4.2)
getMessage(id), authorOf(id), exists(id) read a message
replyGate(id), authorGate(author), effectiveGate(id) read the gates, and the one that would apply now
checkReply(parentId, replier, gateData) preview whether a reply would be admitted
nextId(), nonceBitmap(author, wordPos), DOMAIN_SEPARATOR() the rest of the state

Two gate addresses are reserved and never called:

effectiveGate(id) returns the gate that applies to replies to id right now: the message's own gate if it has one, otherwise its author's default, or zero when neither is set, meaning replies are open. A message's own gate therefore overrides its author's default, deactivation included. The reserved addresses come back as they are: TOMBSTONE means the message was deleted or, when it has no gate of its own, that its author is deactivated, and REMOVED means the author of its parent removed it. An unknown id reverts with MessageNotFound. The query never calls the gate and takes no replier, so it cannot say whether a particular account may reply. The right of response (§5.4) passes a configured gate or a deactivated account, though never a deleted or removed message. Use checkReply to ask about a particular replier.

checkReply runs the same admission code as posting, including the right of response and the gate call, without writing anything. It returns the gate admission depended on, or zero when no gate was called, and reverts with the posting errors. It authenticates nobody and is only a preview: state, gas and transaction context can differ at inclusion.

4.1 Posting rules

On every post and postBySig, before an id is assigned or anything stored (postBySig first checks its deadline and signature and uses its nonce; any later failure undoes that):

  1. replyGate may not be REMOVED (InvalidGate). TOMBSTONE is allowed and makes a message that is born deleted.
  2. A post (parentId == 0) needs nothing more. Posts are never gated; spam control for them belongs to whoever pays the gas.
  3. The parent must exist (ParentNotFound).
  4. The parent must not be deleted (ParentRetracted) or removed (ParentRemoved). Explicit deletion and removal always close replies, protected responses included.
  5. If the replier is the author of the parent's own parent, the reply is admitted without calling any gate: the right of response (§5.4).
  6. Otherwise the effective gate is the parent's own gate if set, else its author's default (§5.7). A deactivated author (TOMBSTONE as the default) rejects the reply (ParentRetracted). Nothing set means anyone may reply.
  7. If there is a gate, the Board calls canReply(parentId, replier, gateData) on it with STATICCALL. The call must succeed and return at least 32 bytes whose first word is exactly 1. Anything else, including a revert, a short or malformed answer, or an address with no code, rejects the reply (GateRejected).
  8. The message is stored and Posted is emitted with the parent's actual author.

4.2 Signed actions (EIP-712)

postBySig, setReplyGateBySig and setAuthorGateBySig let anyone submit an author's signed write and pay its gas. The submitter gains nothing else; attribution stays with the signer.

5. Reply gates

interface IReplyGate {
    function canReply(uint64 parentId, address replier, bytes calldata data)
        external view returns (bool allowed);
}

A gate receives the message being replied to and the replier. When posting, the Board has authenticated the replier; during a checkReply preview it has not. data is opaque extension data: the standard gates ignore it, and clients that know no proofs send empty bytes.

5.1 Semantics

5.2 Changing the rules

The author can change a message's gate at any time with setReplyGate. Changes apply to future replies only: existing replies stay on chain. Posted, ReplyGateChanged and AuthorGateChanged record which gate was in force when.

A gate's behaviour can also change through its own configuration, or through data it reads, such as someone else's list. Rule stability is something an author gets by choosing a gate that is immutable and self-contained, not something the protocol promises.

5.3 Lists and the standard gates

Gates are ordinary contracts outside the Board. These are the standard ones, deployed at the addresses in deployments.json:

5.4 The right of response

The author of a reply's parent may always answer that reply, while the reply is live. If Bob replies to Alice, Alice can answer Bob, whatever the gates on Bob's reply or Bob's account say, and Bob can then answer Alice. The right follows the authenticated author (including a smart-account author), not the key or relayer that submitted the write.

effectiveGate still reports the configured gate; checkReply returns zero for an entitled author.

5.5 Direct replies only

A gate covers replies to its message, not replies to those replies. Bob's reply is Bob's message, and whoever replies to it answers Bob, under Bob's rules. Enforcing a thread-wide rule would let one author control who may talk to another, would change rules under authors who never agreed to them, and would cost a walk up the thread on every reply. Thread-wide protection is a reading concern: clients decide what to show inside a thread.

5.6 Deletion and removal

On-chain data cannot be erased, so deletion is a signal carried by the gate field:

Both are permanent. Once either is set, the message accepts no replies (ParentRetracted or ParentRemoved) and its gate never changes again (MessageTombstoned). Whichever lands first is final. An author can never mark their own message REMOVED, not even a reply to their own post (InvalidGate), so the value always means someone else acted.

The message's fields stay readable and exists(id) stays true. Existing replies to it remain on chain. What readers do:

Hosts may stop serving deleted content, but other copies may exist: deletion cannot guarantee erasure.

5.7 Author defaults and deactivation

Each author has one default gate, set with setAuthorGate. It governs replies to every message of theirs that has no gate of its own, including messages posted before the default was set, since gates are always evaluated at reply time. Pointing it at AuthorBlocklistGate makes blocking a single list write across the whole account.

Gates are checked when replies are posted, so changing a gate never affects earlier replies on chain. Clients may still reapply recognised gates when showing earlier replies. The reference client folds earlier replies from accounts on the parent author's blocklist, whatever gate the message uses, and never folds protected responses.

5.8 Quotes are not replies

A quote is a message whose content references another message with a post:// link (§7). It is not an on-chain link, and it is not subject to the quoted message's gate. A reply takes a place in someone's conversation; a quote stays in the quoter's own space. Quotes are also how a message travels between chains: replies are always on their parent's chain, where its gate runs.

6. Records

Every piece of content is a record: one header line, then the body.

version/1 text/plain
<body bytes>

A reader first hashes the bytes and compares them with the expected hash, then parses the header. Bytes that fail either step are never shown as content, and a media type the reader does not implement is shown as unrecognised content, never as text.

media type what it is a reader…
text/plain a message body renders it as below
image/png, image/jpeg, image/gif, image/webp, image/avif an attached picture may show it inline when referenced as an image
image/svg+xml a vector picture must never let it become a document where scripts run; need not show it at all
anything else another kind of attachment offers it as a download or declines to fetch it, and never renders it inline

New kinds of content, such as polls or profiles, are new media types. Adding one changes nothing on chain or in existing clients, which show it as unrecognised until they learn it.

Message text

A message is a text/plain record. Readers render from the original bytes after checking the hash, never from a form a host derived. The formatting is deliberately small:

A reader may bound how much of a body it interprets and how many links and previews it shows, as long as the rest stays visible as text.

7. References and attachments

Attachments are records referenced from a body with record:. The body commits to them by hash, and the chain commits to the body, so the chain commits to every attachment. Authors upload the body first, then each attachment. Readers fetch attachments after the text, verify each hash and dispatch on the media type (§6).

Size limits belong to hosts, which publish their own. So that a message written once can be hosted anywhere, every host should accept at least this baseline, and clients should stay within it:

baseline
message record 4,000 bytes, header included (a host may accept more, never above 1 MiB)
attachments per message 4: the first four record: references, in order, without duplicates
each attachment 2 MiB, header included

8. Security considerations

9. Deployments

Each contract is deployed through the deterministic deployer proxy (0x4e59b44847b379578588920cA78FbF26c0B4956C) with CREATE2 and a fixed salt, so one version of the code has one address on every chain. The build pins the compiler and its settings and omits the metadata hash, so the address depends only on the source.

contract salt constructor arguments
Board keccak256("Board v1") none
ListRegistry keccak256("ListRegistry v1") none
AuthorBlocklistGate keccak256("AuthorBlocklistGate v1") the Board, the registry, keccak256("blocked")
ClosedReplyGate keccak256("ClosedReplyGate v1") none

The Board deployed this way is the Board of each chain. A copy of the code from another deployer or salt is not the protocol's Board, which is why a post:// reference needs only a chain id.

Each deployment is its own community: the signing domain binds the chain, and every reply-time check is a read of the parent chain's state, so threads never cross chains. Identities do, since the same key has the same address everywhere, and so do quotes.

deployments.json lists the live addresses, with the block at which the Board was deployed on each chain: where an indexer starts reading. A test checks every address in it against the CREATE2 address of the code in this repository, so the two cannot drift apart.