Peal protocol reference · v0
How Peal guarantees every reveal.
Peal is a programmable encryption network for information that must stay unreadable until a shared condition fires. A browser seals a payload once. A threshold committee later opens the whole batch, publicly and verifiably. Nobody returns for a second reveal transaction. This article walks the protocol end to end: the cryptography that makes early reading impossible, the machinery that makes the reveal guaranteed, how to build on it, and exactly what separates today's devnet from production.
The problem
Commit-reveal is the standard way to hide information on a public ledger until a deadline: users post a hash now and the preimage later. The pattern is sound cryptography wrapped around a broken assumption, that people will come back. The bidder who lost never opens their commitment, and the auction cannot distinguish a griefing bidder from a crashed one. The voter who saw the early tally shift never reveals. The game player whose move became bad simply forfeits the reveal and eats a smaller penalty than the move would have cost.
Every application built on voluntary reveal inherits the same design tax: reveal windows and their timeout parameters, slashing deposits sized to guess at the value of hiding, incomplete datasets, and a support queue of users who missed their window honestly. The failure is structural. If revealing is a user action, the user can decline it exactly when declining matters most.
Peal removes the action. The payload is encrypted rather than hashed, and decryption is a network duty rather than a user choice. Before the cue, fewer than t committee operators can recover nothing, and that includes the coordinator that stores the ciphertexts. After the cue, any t valid shares recover every slot in the frozen batch at once. The user's only action is the seal.
Why threshold, not the alternatives
Guaranteed reveal has four other known constructions. Each carries a cost Peal was built to avoid, and each has real uses; the table is a scoping tool, not a dismissal.
| approach | how it reveals | the catch |
|---|---|---|
| timelock puzzle / VDF | anyone grinds sequential computation until the plaintext falls out | the deadline is denominated in compute, not clock time; someone must actually run the grind, and faster hardware moves the deadline |
| drand tlock | a long-running threshold beacon, the League of Entropy, publishes a BLS signature every round; you encrypt to a future round number and anyone decrypts with that round's signature | the schedule is the beacon's rounds, 3 s or 30 s of wall clock, not your condition, and there is no block-height cue; every ciphertext for a round opens for everyone with the same signature, one at a time, and there is no batch, so nothing pads or hides how many were sealed |
| TEE-held keys | an enclave releases the key at the deadline | the guarantee is the vendor's attestation chain; one enclave break is a total, silent compromise |
| naked threshold | a committee decrypts each ciphertext on request | per-ciphertext work: n operators each touch every message, which is exactly what kills throughput at batch sizes that matter |
| peal (batched) | a committee posts one constant-size share per operator per batch | batches are fixed at B slots and a threshold of operators must be live; the v0 ceremony still has a trusted dealer |
The batching is the point. One 48-byte share per operator opens up to 64 payloads, so the committee's work per reveal is constant while the batch fills. Wall-clock deadlines stay wall-clock. And every share is publicly verifiable against published keys, so a lying operator is caught by arithmetic, not by reputation. Detection is live today; the remedy is not: replacing a caught operator currently means a new ceremony.
Why not drand tlock
tlock deserves its own paragraph because it is the closest neighbour, not a strawman. It is the same primitive family, threshold BLS over BLS12-381. It has run in production since 2020. Its own documentation lists the same applications this site does: sealed-bid auctions, MEV prevention, voting, responsible vulnerability disclosure. And the League of Entropy is a genuinely multi-organisation committee, Cloudflare, EPFL, Protocol Labs, Kudelski Security and around twenty other named members. If what you need is a public wall-clock unlock and nothing else, tlock is the mature answer and you should use it.
Peal differs in three specific ways. The condition is yours. A tlock ciphertext opens at a beacon round, which is a point in wall-clock time; a Peal condition is a deadline or a block height on a chain you name, and the cue is a row the coordinator fires rather than a signature somebody else's schedule produces. The unit is a round, not a ciphertext. Up to 64 payloads share one batch; one 48-byte share per operator opens all of them, so committee traffic per reveal is constant while the batch fills. The batch is padded with decoys, so while a round is open nothing says how many were sealed, and once it opens the count is still padded. tlock has no batch: each ciphertext is independent, and a public count of ciphertexts encrypted to a round is exactly the number a competitor in a sealed auction wants. It is HTTP with no chain. Three calls seal and open a round; a chain enters only when you anchor a root to one.
Two things this comparison must not say. Neither scheme is post-quantum: both rest on pairings, and drand says so of tlock in its own docs. And batching saves coordination and bandwidth, not pairings: the paper is explicit that decryption costs a few pairings per ciphertext in every scheme, its own included. What Peal does not have yet is distributed key generation: until the dealer ceremony is replaced, tlock's trust model is the stronger one.
Sources: drand, timelock encryption (mechanism, the applications list, the stated limitations) · drand/tlock (round or duration as the only triggers) · drand, about (beacon cadence, League of Entropy membership) · Policharla, A Simple Batched Threshold Encryption Scheme, ePrint 2026/760 (48-byte shares, the per-ciphertext pairing floor).
What one payload goes through
Fetch committee parameters
The SDK downloads the public parameters, checks their SHA-256 digest, and caches them. The parameter set fixes n, t and B for the committee's lifetime, and the wasm module re-parses it independently, so a tampered parameter blob fails twice.
GET /v0/committees/:idSeal in the browser
Wasm runs the Fujisaki-Okamoto transform locally. Only the sealed wire bytes ever leave the device; for a short text payload that is about 110 bytes total. The plaintext never exists outside the tab that typed it.
seal(payload, conditionId)Content-address the ciphertext
The coordinator parses the wire format, enforces the payload cap, computes ct_hash = sha256(wire), and stores the blob under its condition. The hash is the stable handle an application keeps; submission order does not choose the slot.
POST /v0/ciphertextsThe cue fires, the batch freezes
A wall-clock or block-height condition fires. The coordinator pads to 64 slots with self-sealed dummies, sorts every ciphertext by hash, assigns positions, and makes the batch immutable. Positions are a pure function of the ciphertext set: two coordinators given the same seals produce the same board.
pending → frozenOperators post one share each
Each outbound-only node polls for frozen work, decrypts its local keystore, and computes one 48-byte partial for the entire batch. The share is the same size whether the batch holds one sealed order or sixty-four.
GET /v0/work · POST /v0/sharesVerify, combine, recover
Every share must pass a public pairing check before it counts. Any t valid shares are Lagrange-combined once for the whole batch, and a per-slot integrity check isolates any mauled ciphertext to its own slot.
frozen → revealedPublish an auditable record
The reveal carries every slot, its validity bit, the full operator share log with timings, and a merkle root over position and payload. Anyone can recompute the root from the published batch.
GET /v0/reveals/:id
The condition engine
The coordinator's scheduler is a 500 ms tick loop, not a callback registry. Each pass it freezes any pending wall-clock condition whose time has come, polls configured RPC endpoints with eth_blockNumber for block-height conditions, finalizes any frozen batch that has reached t verified shares, and marks conditions stalled when the reveal timeout (default 120 seconds, REVEAL_TIMEOUT_SECS) passes without them.
Two properties matter more than the loop itself. Freezing is deterministic: positions derive from sorting ciphertext hashes, so the frozen board is reproducible by anyone holding the ciphertext set, and an integration test asserts two coordinators agree. And every step is idempotent against restart: state reloads from the database, the expensive cross-terms are recomputed rather than trusted from cache, and operator nodes simply repoll. Killing the coordinator mid-reveal delays the reveal; it cannot corrupt it.
The cryptography
Peal wraps Commonware's batched threshold encryption (eprint 2026/760 by Guru Vamsi Policharla, implemented in simple-bte) without modifying the scheme. The wrapper adds conditions, persistence, operator transport, share verification, and public records. Nothing below is novel cryptography; that is deliberate.
The wire format
A sealed ciphertext is a fixed header and a masked body. The cryptographic overhead is 64 bytes: a 48-byte KEM header and a 16-byte key mask. Framing adds 9 more.
"BTE0" || 0x01, 5 bytesct₀ = [k]₁, 48 bytes compressed G1ct₁ = Hₖ([k · τB+1]ₜ) xor K, 16 bytesct₂ = Hₘ(K) xor payloadk = Hᵣ(K, payload), verify [k]₁ = ct₀The last line is the Fujisaki-Okamoto discipline that makes the scheme non-malleable in practice: the ephemeral scalar is not random at decryption time, it is re-derived from the recovered plaintext. A ciphertext anyone tampered with recovers to a plaintext whose re-derived scalar fails to reproduce the KEM header, so the slot is flagged corrupt and quarantined without poisoning the other 63.
The punctured setup
The ceremony publishes powers of a secret τ with one deliberate hole. Everyone can encrypt toward the missing power. Nobody holds it. That hole is the entire trick.
[τj]₂ for j = 0 … 2B, except j = B+1 (zeroed)ek = [τB+1]ₜ, target group onlyShamir shares of τ¹ … τB, threshold t of nverification values vᵢʲWhy it works, in one paragraph. Sealing masks the payload key with [k · τB+1]ₜ. That value lives in the pairing's target group, where you cannot climb from [τj] to [τj+1]; the one power that would let you compute it directly was zeroed out of the published set, and its preimage was destroyed at setup (in v0, that destruction is the dealer's promise; see the trust model). What the batch structure buys is a way to route around the hole: pairing each ciphertext header against the right published powers produces cross-terms in which the unknown power appears only multiplied by the operators' secret-shared low powers. Add t operators' contributions and the unknown cancels into something computable; with fewer than t, it is still hidden behind Shamir secrecy. The published powers run to 2B precisely so those cross-terms exist for every slot position.
This is also why every batch is exactly B slots. The setup material and the FFT domain are sized to B at the ceremony; a batch of three cannot be decrypted, so the coordinator pads with self-sealed dummies rather than shrinking the math.
Shares and public verification
pdⱼ = Σᵢ σⱼᵢ · ctᵢ,₀e(pdⱼ, g₂) = Πᵢ e(ctᵢ,₀, vⱼᵢ)Lagrange-combine any t valid shares at x = 0Operator j runs one multi-scalar multiplication over the frozen headers; the result is a single compressed G1 point, 48 bytes, covering the whole batch. The coordinator checks each partial against public verification keys before it counts: a forged share fails the pairing equation, is recorded as rejected under the operator's identity, and never reaches the threshold. Verification needs no secrets, so anyone can re-run it.
Pipelined recovery
The expensive part of decryption does not wait for operators. The FFT cross-terms (O(B log B) group operations plus O(B) pairings, never the naive B² loop) depend only on the frozen ciphertexts and the public parameters, so they compute at freeze time while shares are still in flight; an integration test asserts pre-decrypt finishes before the first share exists. When t verified shares land, finalize is one Lagrange interpolation at x = 0 plus the per-slot FO re-check. On the public devnet, pre-decrypt runs in roughly 250 ms and finalize in 40 to 150 ms for a full 64-slot batch, so the user-visible gap between cue and plaintext is the share round-trip, not the math.
The commitment
sha256(position_le_u32 || payload)sha256(left || right), odd node promoted"BTE_DUMMY_V0:" || 16 random bytesThe merkle root binds every slot, padding included, so a published reveal cannot be edited without detection. Padding slots are real self-sealed ciphertexts with a tagged random payload, unique per batch, and operators do identical work on them, so the padding is not a shortcut. Anyone can download the batch from the reveal endpoint, recompute the root, and compare it with the published or onchain-anchored value.
The ceremony and the keys
v0 key generation is a single offline dealer running simple-bte::crs::setup: it samples τ, computes the punctured public parameters, Shamir-deals the shares of each power to the n operators, writes each share into a password-encrypted keystore (argon2id key derivation, ChaCha20-Poly1305 sealing), publishes the parameter blob, and destroys τ. The parameter set is content-addressed: its SHA-256 digest is the committee id, which is why an application can pin one committee and reject every other.
Operator nodes never hand their keystores to anyone. A node decrypts its keystore locally at startup, holds the share in memory, and speaks only outbound HTTP to the coordinator. The coordinator cannot reach into a node, and a stolen coordinator database contains ciphertexts and public data, not key material.
The dealer is the v0 compromise, stated plainly: whoever ran the ceremony could have kept τ and could decrypt everything early. That is acceptable for a devnet and unacceptable for value. The production replacement is a distributed key generation in which the trapdoor never exists on any single machine, plus proactive resharing so operators can rotate without changing the public key applications pinned.
Two privacy layers
The network proves when. The link decides who. Threshold reveal is deliberately public: after the cue, every slot's plaintext is on the record so anyone can verify the batch. That is exactly right for auctions and votes, whose fairness depends on every participant reading the same reveal, and wrong for a personal note. So a second, purely client-side layer exists on top.
"BTEP1" || iv (12 bytes) || AES-128-GCM(key, text)#/s/<code>/<key>Before sealing, the browser wraps a personal payload in AES-128-GCM. The key never reaches any server: it rides only in the share link's hash fragment, which browsers do not transmit. The network still proves when the seal opened; only people holding the full link learn what it said. The explorer marks such slots private instead of printing ciphertext, and a link that lost its key segment gets a clear error rather than garbage. The trade is explicit: there is no recovery path. Lose every copy of the link and the content stays unreadable, by construction.
Architecture
The design separates the public edge from secret-bearing operators. The browser and explorer are public. The coordinator is a scheduler and aggregator that never sees pre-cue plaintext. Operator nodes accept no inbound connections and never expose their keystores.
| component | role |
|---|---|
| bte-sdk | fetches and digest-checks parameters, seals in wasm, submits ciphertexts, waits for reveals, optionally verifies the anchored root |
| bte-coordinator | condition engine, SQLite state machine, deterministic freeze, pipelined pre-decrypt, pairing checks, REST /v0 |
| bte-node | polls outbound for frozen work, decrypts its local argon2id + ChaCha20 keystore, computes one partial, posts it |
| BteAnchor.sol | commits ciphertext hashes to conditions and records the final merkle root from an authorized publisher |
The public API, in one table
| endpoint | caller | purpose |
|---|---|---|
| GET /v0/committees/:id | everyone | public parameters, digest-checked by clients |
| POST /v0/conditions | apps | create a cue: at a time, in N seconds, or at a block height, with an optional tag |
| GET /v0/conditions[/:id] | everyone | status, fires_at, counts, tag, per-batch share progress |
| POST /v0/ciphertexts | apps | submit a sealed blob to a pending condition |
| GET /v0/work | operators | frozen batches awaiting this operator's share |
| POST /v0/shares | operators | submit a 48-byte partial; pairing-verified on arrival |
| GET /v0/reveals/:id | everyone | 404 until revealed, then slots, share log, timings, merkle root |
The reveals endpoint returning 404 before the cue is itself an invariant with a test behind it: the test greps the coordinator's database for plaintext bytes while a condition is pending and must find none.
The byzantine drill
The claim that verification catches liars is tested, not asserted. The repo ships a drill (just demo-byzantine) that runs the full sealed-bid auction with two failures injected at once: operator 2 runs a byzantine build that submits a corrupt share, and operator 5 is killed mid-flow. The recorded outcome, verified through the API rather than the demo's own output: operator 2's share arrives, fails the pairing equation, and is stored as rejected under its identity; operator 5 never submits; the reveal completes from the three honest shares. The auction's winner is identical to the clean run.
That is the failure envelope in one sentence: up to n - t operators can lie or die simultaneously, attribution is automatic, and the reveal is bit-exact regardless of which honest subset supplied the shares.
Building on it
The product path is four calls: create a condition, seal locally, store the returned hash, wait for the reveal.
import { BteClient } from 'bte-sdk';
// one client, pointed at the network
const peal = new BteClient({ url: 'https://peal.network' });
// a cue 60 seconds out, tagged so this app finds its own rounds
const conditionId = await peal.condition({ in: 60, tag: 'auction-v1' });
// seal locally; bind the app context inside the encrypted bytes
const sealed = await peal.seal(JSON.stringify({
app: 'auction-v1',
conditionId, // replay binding
lotId: 'lot-42',
bid: 815,
nonce: crypto.randomUUID()
}), conditionId);
// the network does the rest; no second transaction exists
const reveal = await peal.waitForReveal(conditionId);
const slot = reveal.slots.find((s) => s.ctHash === sealed.ctHash);
if (!slot?.valid) throw new Error('sealed bid did not recover');
console.log(slot.text);
- Pin the committee digest. Ship the expected public-parameter digest with your app; do not silently accept a coordinator-selected committee.
- Bind the payload. Put the condition id, app domain, action type, and a nonce inside the encrypted bytes and validate them after reveal, so a copied ciphertext cannot be replayed into a different context undetected.
- Persist the ciphertext hash. It is the stable handle for the commitment. Store it in your database or anchor it onchain before the cue.
- Tag your conditions. The optional tag (up to 32 characters of a-z 0-9 : _ -) lets your app find its own rounds and never join a stranger's. Peal Network's explorer keeps bid rounds, vote rounds, and capsules apart this way.
- Treat reveal as asynchronous. Poll or index; handle pending, frozen, stalled, revealed, and per-slot corrupt states explicitly.
- Layer client-side encryption for personal data. The network reveal is public; if only the recipient should read the content, encrypt inside the payload and carry the key in your own channel, as private capsules do in the link fragment.
Failure behavior is explicit
| failure | behavior |
|---|---|
| one node offline | reveal continues with any 3 of 5 |
| one forged share | pairing check rejects it; the operator identity stays visible in the log |
| one mauled ciphertext | that slot is marked corrupt; the other 63 recover |
| coordinator restart | state reloads from the database, pre-decrypt recomputes, nodes repoll |
| fewer than t shares | the condition stalls after 120 s; it is never falsely revealed |
| late valid share | a stalled condition completes automatically |
The numbers
Measured on the public devnet (n = 5, t = 3, B = 64), not estimated.
| quantity | value | note |
|---|---|---|
| seal overhead | 64 bytes | 48-byte KEM header + 16-byte key mask |
| wire size, short text | ~110 bytes | framing adds magic, type, length |
| operator share | 48 bytes | one compressed G1 point per operator per batch, any fill |
| pre-decrypt | ~250 ms | pipelined away: runs at freeze, before any share exists |
| finalize | 40 to 150 ms | user-visible: Lagrange combine + per-slot FO re-check |
| engine tick | 500 ms | freeze scheduling granularity |
| stall timeout | 120 s | REVEAL_TIMEOUT_SECS; late shares still complete |
| rate limit | 50 rps, burst 400 | per client IP, token bucket |
Production posture
The current stack runs a transparent public devnet: a real threshold committee, public share verification, durable state on a mounted volume, recovery after restart, TLS, rate limiting, and honest stall states. The decisive blocker for real value is the ceremony.
| layer | v0 today | production target |
|---|---|---|
| key generation | offline trusted dealer | audited DKG; no machine ever knows the whole trapdoor |
| operator lifecycle | new ceremony to replace one | proactive resharing and rotation under a stable public key |
| availability | coordinator database + volume | replicated store plus blob or calldata copies of ciphertexts |
| accountability | invalid shares attributable | stake, slashing, signed work receipts |
| verification | offchain pairing check, anchored root | EIP-2537 onchain verification of shares and combination |
| operations | health endpoint, structured logs | SLOs, metrics, paging, tracing, backups |
| security | unaudited prototype | independent audits and ceremony review |
The trust model, stated precisely
You do not trust
- the coordinator with pre-cue plaintext
- any coalition smaller than the threshold
- an operator's claim that its share is valid
- the explorer's arithmetic; recompute the root yourself
V0 still requires trust
- the dealer did not retain or leak τ
- fewer than three of the five operators collude
- at least t operators answer after the cue
- the coordinator includes every submitted ciphertext
- the deployment preserves ciphertext availability
v0 is dealer-trusted and unaudited. Use it for testnets, demos, and integration work. DKG and an independent audit are prerequisites for a stronger claim.