Contents
ZRC-20
A fungible token standard for Zcash. Tokens are JSON documents written into the encrypted memo field of shielded outputs; balances are derived off-chain by indexers that replay those documents in block order. No consensus change, no smart contracts.
What it is
ZRC-20 borrows its shape from BRC-20 on Bitcoin. The chain carries text and nothing else. Every participant runs the same deterministic reducer over that text, and everyone who replays the same blocks arrives at the same balance sheet. Consensus never learns that a token exists.
What changes on Zcash is the carrier. BRC-20 inscribes into the taproot witness, which is public the instant it confirms. ZRC-20 writes into a shielded note's memo field, which is encrypted and readable only by someone holding the right viewing key. That single substitution is the whole design, and most of this document is about its consequences.
What ZRC-20 is not
- It is not a consensus change. No ZIP is required to start using it, and Zcash nodes will neither validate nor reject a ZRC-20 operation.
- It is not a smart contract system. There is no execution, no state root, no gas. The reducer lives in the indexer.
- It is not automatically private. As set out under Visibility, a publicly tradeable token needs a published viewing key, which makes the operations public even though the surrounding transaction stays shielded.
Lifecycle
Three operations, exactly as in BRC-20: deploy creates a ticker and fixes its supply, mint issues against it until supply is exhausted, and transfer moves a balance. Each is a single Zcash transaction carrying one memo.
The memo carrier
Every Sapling or Orchard shielded output contains a note ciphertext, and inside that ciphertext sits a memo field of exactly 512 bytes. It is always present and always that length. A memo shorter than 512 bytes is padded with zeros before encryption, so the size of what you wrote never leaks to a chain observer — only that something was written, which is true of every shielded output whether it carries a memo or not.
ZIP-302 encoding
Zcash's draft ZIP-302 assigns meaning to the memo's first byte. A ZRC-20 payload is JSON, and a JSON object opens with {, which is 0x7B. That falls inside the plain-text range, so a ZRC-20 memo is an ordinary ZIP-302 UTF-8 text memo and any wallet that can send a text memo can write one. No new encoding, no wallet changes.
| First byte | ZIP-302 meaning | Used by ZRC-20 |
|---|---|---|
| 0x00–0xF4 | UTF-8 text memo, trailing zeros trimmed | Yes — JSON begins at 0x7B |
| 0xF5 | Legacy range, reserved by private agreement | No |
| 0xF6 | "No memo" (followed by all zeros) | No |
| 0xF7 | Structured TVLV memo (draft) | Not yet — see Open questions |
| 0xF8–0xFE | Reserved for future protocol extensions | No |
| 0xFF | Arbitrary binary | No |
Encoding rules
- The memo MUST be valid UTF-8 and MUST parse as a single JSON object.
- Bytes after the JSON document MUST be zero. An indexer trims trailing zeros before parsing.
- The document MUST NOT exceed 512 bytes once serialized. There is no continuation across outputs in this version.
- Whitespace is permitted but wasteful; writers SHOULD emit compact JSON with no spaces.
- Keys are case-sensitive and lowercase. Unknown keys MUST be ignored rather than treated as an error, so the standard can be extended without forking indexers.
Visibility and viewing keys
This is the part of ZRC-20 with no BRC-20 equivalent, and it deserves care. A shielded memo is decryptable by exactly two parties: the recipient, using their incoming viewing key, and the sender, using their outgoing viewing key. An indexer is neither. If nobody can read the memos, nobody can compute balances, and there is no token.
There are three ways out, and the choice determines what kind of asset ZRC-20 is.
A · Public protocol address (the default in this draft)
Every operation is sent as a zero-value or dust Orchard output to a single well-known unified address — the protocol address — whose incoming viewing key is published. Anyone can sync that key, decrypt every ZRC-20 memo ever written, and build the same balance sheet. The operations become public, but two things stay shielded: the sender's identity, and every other output of the funding transaction. The token behaves like BRC-20 while the money behind it does not.
B · Per-holder disclosure
Operations are sent to the counterparty rather than a protocol address. Balances are genuinely private and there is no global ledger — only pairwise proofs that a holder can disclose by sharing a transaction view key. This is closer to Zcash's spirit and much harder to build a market on: an exchange cannot verify a deposit it cannot see, and total supply is unknowable.
C · Transparent OP_RETURN
Zcash inherits Bitcoin's transparent script layer, where OP_RETURN is still capped at 80 bytes. Fully public, indexable by anyone, and too small for an operation carrying an address and a signature. Listed for completeness; not recommended.
Option A is assumed throughout this document
Everything below — the ownership model, the signature requirement, the indexer design — follows from choosing a public protocol address. If ZRC-20 adopts option B instead, the ownership and validation sections change substantially, because there is no longer a single authoritative replay.
Ownership and authorization
BRC-20 gets authorization for free. An inscription lives on a specific satoshi, and whoever controls the UTXO holding that satoshi controls the inscription; moving it is the transfer. Bitcoin's own signature validation does the work.
ZRC-20 has no such anchor. A memo is just text attached to a note that gets spent and forgotten, and the sender of a shielded output is deliberately unlinkable. There is nothing in the transaction that tells an indexer who sent it. Without an anchor, anyone could write {"op":"transfer","amt":"1000"} and claim to be draining someone else's balance.
So ZRC-20 carries authorization inside the payload:
- An account is identified by
acct— the Bech32m encoding of a 20-byte hash of an Ed25519 public key. It is a ZRC-20-level identity, not a Zcash address, and it is deliberately unlinked from any address the holder uses on-chain. - A mint or transfer MUST carry
sig: an Ed25519 signature over the canonical serialization of the operation with thesigkey removed. - Each account has a nonce, starting at 0. An operation MUST carry
nequal to the account's current nonce; a valid operation increments it. This prevents replay of a memo that anyone can read. - The public key MUST be supplied in
pkthe first time an account acts, so the indexer can bind key to account. Later operations MAY omit it, and the indexer uses the bound key.
Canonical serialization
The signature is taken over the operation's keys sorted lexicographically, compact JSON, UTF-8, with sig excluded:
message = utf8( json_compact( sort_keys( op_without_sig ) ) )
sig = ed25519_sign( sk, message )
Both sides of an implementation must agree on this exactly, so indexers MUST reject an operation whose JSON contains duplicate keys rather than picking one.
Why not derive identity from a Zcash address?
A transparent address would work and would save the pk bytes, but it forces every ZRC-20 holder to touch the transparent pool, which defeats the point of building on Zcash. A shielded address cannot be used, because an indexer has no way to verify that a given shielded address authored a given output.
Operations
All numeric values are decimal strings — never JSON numbers, which cannot represent a uint128 exactly. Amounts are in base units; dec is presentational only.
deploy
Claims a ticker and fixes its issuance schedule permanently. Requires no signature: the first valid deploy wins, and there is nothing yet to authorize against.
| Field | Required | Type | Notes |
|---|---|---|---|
| p | yes | string | Always "zrc-20" |
| op | yes | string | Always "deploy" |
| tick | yes | string | 1–8 bytes after NFC normalization and lowercasing |
| max | yes | uint string | Total supply in base units; must be > 0 |
| lim | no | uint string | Maximum per mint; defaults to max |
| dec | no | 0–18 | Display decimals; defaults to 18 |
| start | no | uint string | Block height at which minting opens |
{"p":"zrc-20","op":"deploy","tick":"zrc","max":"21000000","lim":"1000","dec":"8"}
mint
Issues new units against a deployed ticker, up to lim per operation, until max is reached.
| Field | Required | Type | Notes |
|---|---|---|---|
| p, op, tick | yes | string | op is "mint" |
| amt | yes | uint string | Must be > 0 and ≤ lim |
| acct | yes | string | Account credited |
| n | yes | uint string | Account nonce |
| pk | first use | base64 | 32-byte Ed25519 public key |
| sig | yes | base64 | 64-byte signature |
{"p":"zrc-20","op":"mint","tick":"zrc","amt":"1000",
"acct":"zrc1q9k…","n":"0","pk":"Gv7…","sig":"3nQ…"}
transfer
Moves a balance between two accounts. A single operation, not the two-step inscribe-then-send dance BRC-20 requires.
| Field | Required | Type | Notes |
|---|---|---|---|
| p, op, tick | yes | string | op is "transfer" |
| amt | yes | uint string | Must be ≤ sender's balance |
| acct | yes | string | Sender, and the account the signature binds to |
| to | yes | string | Recipient account; MUST differ from acct |
| n | yes | uint string | Sender's nonce |
| sig | yes | base64 | 64-byte signature |
{"p":"zrc-20","op":"transfer","tick":"zrc","amt":"500",
"acct":"zrc1q9k…","to":"zrc1m4x…","n":"7","sig":"9Lp…"}
Validation rules
An operation is either valid (it changes state) or inert (it is ignored). There is no third outcome and no error state on chain — an inert operation still cost its author a Zcash fee.
General
- The memo must decode as ZIP-302 text, parse as a JSON object, and carry
p == "zrc-20". Anything else is not a ZRC-20 operation at all. - Duplicate keys make the operation inert.
- An unrecognized
opis inert. Unrecognized fields are ignored, which is what lets the standard grow. - Numeric strings are decimal, unsigned, without leading zeros, without exponent. A value exceeding
2¹²⁸ − 1is inert. - A transaction may carry several operations, one per shielded output. Each is evaluated independently, in output order.
deploy
- Inert if
tickis already deployed. The winner is the operation with the lowest(height, tx index, output index). - Inert if
maxis zero, or iflimis zero or greater thanmax.
mint
- Inert if
tickis not deployed, or if the current height is belowstart. - Inert if
amtexceedslim. - Inert if the ticker is fully minted. If the mint would cross
max, it is partially filled to exactly the remaining supply. This matches BRC-20's behaviour and makes the tail of a fair mint a race rather than a cliff. - Inert if
nis not the account's current nonce, or the signature fails.
transfer
- Inert if the sender's balance is below
amt. Transfers never partially fill. - Inert if
toequalsacct. - Inert if
nis not the sender's current nonce, or the signature fails. - A valid transfer increments the sender's nonce. The recipient's nonce is untouched.
Ordering, finality and reorgs
Determinism comes entirely from ordering, so it must be stated exactly. Operations are applied in ascending order of:
(block height, transaction index within block, shielded output index within transaction)
Orchard and Sapling outputs are ordered as the transaction serializes them, with all Sapling outputs preceding all Orchard outputs within a single transaction. Two indexers that disagree here will disagree on the winner of a contested mint, which is the only place this usually matters — and it matters most at exactly the moment a fair mint sells out.
Reorgs
Zcash targets 75-second blocks, so a reorg costs less wall-clock time than on Bitcoin but happens proportionally more often. An indexer SHOULD:
- Keep an undo log per block so state can be rolled back rather than resynced from genesis.
- Expose two views: provisional (chain tip) and confirmed (tip minus N blocks). This draft suggests N = 10, about twelve and a half minutes.
- Never report a balance as final at fewer than N confirmations to an exchange or bridge.
Cost
Each operation is an ordinary shielded Zcash transaction and pays ordinary ZIP-317 fees. There is no protocol fee in ZRC-20 itself. A deploy costs the same as a transfer, which means ticker squatting is cheap — a known weakness inherited from BRC-20 and discussed under Open questions.
Byte budget
512 bytes sounds generous until authorization is priced in. A worked transfer:
| Component | Bytes | Notes |
|---|---|---|
| JSON scaffolding (braces, quotes, colons, commas) | ~38 | Compact, no whitespace |
"p":"zrc-20" | 12 | Fixed cost of self-description |
"op":"transfer" | 15 | Longest of the three op names |
"tick" + value | ~13 | Assuming a 4-byte ticker |
"amt" + value | ~14 | Decimal string |
"acct" + "to" | ~88 | Two Bech32m account strings |
"n" + value | ~8 | Nonce |
"sig" + value | ~94 | 64-byte Ed25519 signature, base64 |
"pk" + value (first use only) | ~50 | 32-byte public key, base64 |
| Total | ~282 / 332 | Comfortably inside 512 |
Roughly 180 bytes of headroom remain, which is the space available for future fields. Two obvious savings if it ever gets tight: drop base64 for base58 or raw bytes under a 0xFF binary memo, and shorten key names to single characters. Both trade legibility for room, and neither is needed yet.
Size limits
The ceilings that bound the whole design, on both chains.
The asymmetry is larger than it first looks. BRC-20 can afford to be verbose because an inscription has kilobytes to spare; ZRC-20 pays for every character, which is why the field names are short and why signatures are the single largest line item in the budget above.
ZRC-20 vs BRC-20
Same idea — inscribe JSON, let an indexer keep score — with very different carriers.
Writing an indexer
An indexer is a pure function from a block range to a balance sheet. Given the same blocks and the same viewing key, two independent implementations must produce byte-identical state — that is the only guarantee ZRC-20 offers, and it is enough to build a market on.
Pipeline
- Sync. Follow the chain with
zebrador a lightwalletd instance and trial-decrypt every Sapling and Orchard output with the protocol address's incoming viewing key. - Extract. For each successful decryption, take the 512-byte memo, trim trailing zeros, and attempt a UTF-8 JSON parse.
- Filter. Discard anything without
p == "zrc-20". Most decrypted memos will be ordinary payment notes. - Order. Sort by
(height, tx index, output index). - Reduce. Apply the validation rules in order, writing an undo record per block.
Reducer sketch
def apply(state, op, ctx):
if op.get("p") != "zrc-20":
return state # not ours
kind = op.get("op")
if kind == "deploy":
tick = normalize(op["tick"])
if tick in state.tokens:
return state # inert: already claimed
state.tokens[tick] = Token(
max=uint(op["max"]),
lim=uint(op.get("lim", op["max"])),
dec=int(op.get("dec", 18)),
start=uint(op.get("start", 0)),
minted=0,
)
return state
if not verify_sig(op, state): # binds pk -> acct, checks nonce
return state
if kind == "mint":
t = state.tokens.get(normalize(op["tick"]))
if t is None or ctx.height < t.start:
return state
amt = uint(op["amt"])
if amt == 0 or amt > t.lim or t.minted >= t.max:
return state
amt = min(amt, t.max - t.minted) # partial fill at the tail
t.minted += amt
state.credit(op["acct"], t, amt)
elif kind == "transfer":
t = state.tokens.get(normalize(op["tick"]))
amt = uint(op["amt"])
if t is None or op["to"] == op["acct"]:
return state
if state.balance(op["acct"], t) < amt:
return state # no partial fills
state.debit(op["acct"], t, amt)
state.credit(op["to"], t, amt)
state.bump_nonce(op["acct"])
return state
Conformance
Any implementation claiming ZRC-20 support should agree on: partial fills at the mint tail, nonce strictness, the Sapling-before-Orchard output ordering, and rejection of duplicate JSON keys. Those four are where independent indexers realistically diverge.
Open questions
Honest gaps in this draft, listed so implementers do not mistake silence for settled design.
- Protocol address vs per-holder disclosure. Option A is assumed but not committed. Option B produces a meaningfully different asset.
- Ticker squatting. Deploy is free beyond the Zcash fee, so every three-letter ticker will be claimed within days of launch. A deposit, a burn, or a commit-reveal auction would all help; none is specified.
- Atomic trading. BRC-20 markets rely on partially signed Bitcoin transactions. There is no equivalent here yet, so the first exchanges will necessarily be custodial.
- Payloads over 512 bytes. No continuation format is defined. ZIP-231's larger memo bundles, if activated, would remove the need for one.
- Structured memos. ZIP-302's TVLV container (
0xF7) would be more compact than JSON and would let a ZRC-20 payload share a memo with a human-readable note. It is still a draft. - Name collision. "ZRC-20" is already ZetaChain's omnichain token standard, and Zilliqa uses "ZRC-2". Worth deciding deliberately rather than discovering later.
Brand assets
Archivo ExtraBold for display, JetBrains Mono for payloads. Ink #0D0D0D, ground #FCFCF9, and gold #F4B728 for emphasis.