ZRC20
ZRC-20
Fungible tokens on Zcash
Contents
  1. Overview
  2. Lifecycle
  3. The memo carrier
  4. Visibility & viewing keys
  5. Ownership & authorization
  6. Operations
  7. Validation rules
  8. Ordering & reorgs
  9. Byte budget
  10. Size limits
  11. ZRC-20 vs BRC-20
  12. Writing an indexer
  13. Open questions
  14. Brand assets
SPECIFICATION · DRAFT

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 byteZIP-302 meaningUsed by ZRC-20
0x00–0xF4UTF-8 text memo, trailing zeros trimmedYes — JSON begins at 0x7B
0xF5Legacy range, reserved by private agreementNo
0xF6"No memo" (followed by all zeros)No
0xF7Structured TVLV memo (draft)Not yet — see Open questions
0xF8–0xFEReserved for future protocol extensionsNo
0xFFArbitrary binaryNo

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.

DECISION REQUIRED

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 the sig key removed.
  • Each account has a nonce, starting at 0. An operation MUST carry n equal 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 pk the 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.

FieldRequiredTypeNotes
pyesstringAlways "zrc-20"
opyesstringAlways "deploy"
tickyesstring1–8 bytes after NFC normalization and lowercasing
maxyesuint stringTotal supply in base units; must be > 0
limnouint stringMaximum per mint; defaults to max
decno0–18Display decimals; defaults to 18
startnouint stringBlock 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.

FieldRequiredTypeNotes
p, op, tickyesstringop is "mint"
amtyesuint stringMust be > 0 and ≤ lim
acctyesstringAccount credited
nyesuint stringAccount nonce
pkfirst usebase6432-byte Ed25519 public key
sigyesbase6464-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.

FieldRequiredTypeNotes
p, op, tickyesstringop is "transfer"
amtyesuint stringMust be ≤ sender's balance
acctyesstringSender, and the account the signature binds to
toyesstringRecipient account; MUST differ from acct
nyesuint stringSender's nonce
sigyesbase6464-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

  1. 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.
  2. Duplicate keys make the operation inert.
  3. An unrecognized op is inert. Unrecognized fields are ignored, which is what lets the standard grow.
  4. Numeric strings are decimal, unsigned, without leading zeros, without exponent. A value exceeding 2¹²⁸ − 1 is inert.
  5. A transaction may carry several operations, one per shielded output. Each is evaluated independently, in output order.

deploy

  1. Inert if tick is already deployed. The winner is the operation with the lowest (height, tx index, output index).
  2. Inert if max is zero, or if lim is zero or greater than max.

mint

  1. Inert if tick is not deployed, or if the current height is below start.
  2. Inert if amt exceeds lim.
  3. 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.
  4. Inert if n is not the account's current nonce, or the signature fails.

transfer

  1. Inert if the sender's balance is below amt. Transfers never partially fill.
  2. Inert if to equals acct.
  3. Inert if n is not the sender's current nonce, or the signature fails.
  4. 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:

ComponentBytesNotes
JSON scaffolding (braces, quotes, colons, commas)~38Compact, no whitespace
"p":"zrc-20"12Fixed cost of self-description
"op":"transfer"15Longest of the three op names
"tick" + value~13Assuming a 4-byte ticker
"amt" + value~14Decimal string
"acct" + "to"~88Two Bech32m account strings
"n" + value~8Nonce
"sig" + value~9464-byte Ed25519 signature, base64
"pk" + value (first use only)~5032-byte public key, base64
Total~282 / 332Comfortably 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

  1. Sync. Follow the chain with zebrad or a lightwalletd instance and trial-decrypt every Sapling and Orchard output with the protocol address's incoming viewing key.
  2. Extract. For each successful decryption, take the 512-byte memo, trim trailing zeros, and attempt a UTF-8 JSON parse.
  3. Filter. Discard anything without p == "zrc-20". Most decrypted memos will be ordinary payment notes.
  4. Order. Sort by (height, tx index, output index).
  5. 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.