Normative specification · Version 2026.09.01
SRC-20 specification
SRC-20 is a fungible token protocol on Bitcoin. Its operations are plain JSON documents embedded in transaction output scripts, which every full node must keep, rather than in witness data that a node is free to discard.
Every rule below is numbered and traceable to the implementation that enforces it. The reference implementation is the Bitcoin Stamps indexer, stampchain-io/btc_stamps; the constants quoted here were read from indexer/src/config.py, indexer/src/index_core/src20.py, indexer/src/index_core/transaction_utils.py, indexer/src/index_core/script.py and indexer/src/index_core/models.py at indexer version 1.9.3.
SRC-20 has no formal standards body. In practice the protocol is what the indexers agree on, so where this document states a rule it names the code that produces it. Where implementations could reasonably differ, the document says so instead of inventing an answer.
Contents
1. Scope and terminology
This document specifies how an indexer derives SRC-20 token state from the Bitcoin blockchain. It does not specify wallet behaviour, fee policy, or any marketplace protocol.
- S-1
SRC-20 is defined on Bitcoin mainnet only. No testnet or signet deployment is recognised by the reference indexer.
- S-2
SRC-20 is an account ledger. A balance belongs to a Bitcoin address, not to a specific unspent output. Spending the output that carried an operation does not move, destroy, or otherwise affect the balance.
- S-3
The creator (sender) of an operation is the address that controls the output being spent by input 0 of the transaction. Other inputs are irrelevant to protocol semantics.
- S-4
The destination (recipient) of an operation is the address encoded in output 0 of the transaction. Later outputs carry data, not intent.
- S-5
SRC-20 is a sub-protocol of Bitcoin Stamps. It shares the Stamps data carrier and the
stamp:prefix with SRC-721 and SRC-101, and is distinguished from them only by thepfield of its JSON payload.
Rules S-3 and S-4 come from get_tx_info() in transaction_utils.py, which derives source from the previous output spent by ctx.vin[0] and destination from ctx.vout[0].scriptPubKey.
2. The data carrier
An SRC-20 operation is a UTF-8 JSON document. To put that document on Bitcoin, it is split across the scripts of transaction outputs. Two carriers exist. Both are still indexed; which one a transaction may use depends on its block height.
Plate I · Carrier A: bare multisig
Plate II · Carrier B: OLGA P2WSH
3. Why the output script matters
This is the design claim that distinguishes SRC-20 from inscription-based token protocols such as BRC-20, and it is worth stating precisely rather than as a slogan.
Plate III · Where the bytes live
- U-1
SRC-20 payload bytes are part of an output's
scriptPubKey. Any node that maintains a full unspent output set holds those bytes for as long as the outputs remain unspent, which in practice is forever, because the outputs are unspendable by construction. - U-2
Neither carrier is spendable in practice. Under Carrier A the third public key of the multisig is a burn key nobody holds a private key for. Under Carrier B the 32 bytes are payload rather than the hash of any real script, so no witness can satisfy them.
- U-3
The honest statement of the tradeoff: SRC-20 buys durability by permanently enlarging every full node's UTXO set, and pays full non-witness transaction weight for the privilege. Inscriptions make the opposite trade. Neither payload can be altered after confirmation; the difference is the cost of guaranteed availability, not the strength of the commitment.
- U-4
A claim that inscription data "can be deleted" is too strong. Witness data is committed by the block header and is retained by every archival node. The accurate claim is that a validating node is permitted to discard it, while output scripts must be retained to validate future spends.
4. Encoding rules
4.1 Carrier A: bare multisig
- E-1
A data output is an output whose script assembles to exactly
OP_1 <pk1> <pk2> <pk3> OP_3 OP_CHECKMULTISIG. Any other multisig shape is rejected by the decoder. - E-2
pk3must be one of five recognised burn keys. If it is, the transaction's keyburn flag is set to 1. An SRC-20 operation carried by multisig is only recognised when keyburn is 1. The recognised keys are:022222222222222222222222222222222222222222222222222222222222222222 033333333333333333333333333333333333333333333333333333333333333333 020202020202020202020202020202020202020202020202020202020202020202 030303030303030303030303030303030303030303030303030303030303030302 030303030303030303030303030303030303030303030303030303030303030303 - E-3
Data bytes are taken from
pk1andpk2of every data output, in output order, aspubkey[1:-1]. That is, the leading sign byte and the trailing byte of each 33-byte compressed key are dropped, yielding 31 data bytes per public key and 62 per output. - E-4
The concatenated bytes are decrypted with ARC4. The key is the txid of the output spent by input 0, in byte-reversed form, that is, the txid as normally displayed. This binds the payload to the specific input being spent.
- E-5
After decryption, bytes 2 through 7 must equal the ASCII prefix
stamp:. If they do not, the transaction is not a Stamps transaction and is ignored. - E-6
Bytes 0 and 1 are a big-endian unsigned 16-bit length covering the prefix and the payload. If the decrypted buffer is shorter than
2 + length, decoding fails with an invalid-data-length error and the transaction is ignored. - E-7
The payload is
chunk[8 : 2 + length], that is, everything after the prefix up to the declared length. Trailing bytes beyond the declared length are ignored.
4.2 Carrier B: OLGA P2WSH
- E-8
A data output is a P2WSH output, that is, a script of
OP_0 <32 bytes>, at output index greater than 0, in a transaction at height 865000 or above. Output 0 is the recipient and is never treated as data. - E-9
The 32-byte witness programs are concatenated in output order and trailing zero bytes are stripped from the end of the concatenated buffer.
- E-10
Bytes 0 and 1 are a big-endian unsigned 16-bit length, as in E-6. The slice
buffer[2 : 2 + length]must begin withstamp:; the payload is that slice with the prefix removed. No decryption is applied. - E-11
Transactions decoded through Carrier B have keyburn set to 1 by the decoder, since the burn-key requirement of E-2 has no equivalent here.
- E-12
Carrier B takes precedence. If a transaction contains any qualifying P2WSH data output, the multisig branch of E-1 is not attempted, even when the P2WSH length check in E-10 fails and the transaction ends up carrying nothing. Implementers must reproduce this exclusion: adding a multisig fallback here changes which transactions are valid and forks the index.
OP_RETURN outputs do not carry SRC-20 payloads. The indexer notices whether a transaction has an OP_RETURN output, but only to exclude certain classic Stamps; it never reads SRC-20 JSON from one. OLGA transactions commonly contain an OP_RETURN output for unrelated reasons, and the flag is explicitly reset for them.
5. Activation heights
SRC-20 has changed behaviour at specific Bitcoin block heights. An indexer that does not reproduce these heights will not reproduce the ledger.
| Height | Constant | Effect on SRC-20 |
|---|---|---|
788041 | CP_SRC20_GENESIS_BLOCK | First SRC-20 activity. Operations at this stage ride inside Counterparty issuances. |
793068 | BTC_SRC20_GENESIS_BLOCK | First SRC-20 carried directly on Bitcoin with no Counterparty encoding. |
796000 | CP_SRC20_END_BLOCK | Every Counterparty-encoded SRC-20 operation at or after this height is ignored. From here the protocol is direct-to-Bitcoin only. |
833000 | CP_P2WSH_FEAT_BLOCK_START | Numeric parsing becomes strict. Below this height a numeric string had every character that was not a digit or a dot silently removed before parsing; at and above it, a malformed numeric string excludes the transaction. See rule N-6. |
865000 | BTC_SRC20_OLGA_BLOCK | Carrier B (OLGA P2WSH) becomes available for SRC-20. |
Height 833000 is not about P2WSH for SRC-20, despite the constant's name. For SRC-20 it is the height at which numeric strings stopped being scrubbed. A payload with "max": "21,000,000" was read as 21000000 before 833000 and is rejected outright after it. Any indexer that applies today's strict rule to historical blocks will produce a different supply for affected tokens.
6. The JSON payload
- P-1
The payload is a single UTF-8 JSON object. It is parsed once; a parse failure excludes the transaction silently.
- P-2
Numbers in scientific notation are rejected. During JSON parsing, any numeric literal whose text contains
eorEraises an error and the whole transaction is excluded.1e6is not a valid supply. - P-3
The
pfield selects the sub-protocol and is compared case-insensitively: a payload is SRC-20 whenplowercased equalssrc-20.SRC-20,src-20andSrc-20are all accepted;SRC20without the hyphen is not. - P-4
The
opfield is uppercased before dispatch, sodeploy,DeployandDEPLOYare equivalent. Recognised values areDEPLOY,MINTandTRANSFER. Anything else is recorded as an unsupported operation and is invalid. - P-5
Field presence is checked as a superset. A payload qualifies for an operation's shape when it contains at least that operation's required keys. Unknown extra fields do not invalidate a payload; they are carried along and, for DEPLOY, some of them are stored as metadata.
- P-6
An empty string value in any field is normalised to null before validation. A field present with value
""is treated as a field that was not supplied. - P-7
Numeric fields accept either a JSON string or a JSON number. Both are converted to arbitrary-precision decimals. Floats are formatted without exponent before conversion.
BRC-20 uses "p": "brc-20" and requires string values for amounts. SRC-20 uses "p": "src-20" and accepts numbers as well as strings. The field names tick, max, lim, amt look identical between the two protocols, which is exactly why payloads get copied across and then fail: the ticker rules, the decimal defaults and the carrier are all different.
7. Ticker rules
- T-1
A ticker is at most 5 code points. The limit is counted in Unicode code points, not bytes, so a five-emoji ticker is legal and occupies 20 bytes.
- T-2
A ticker must be non-empty and every character must belong to the allowed set. The allowed ASCII characters are exactly:
. ! # $ % & ( ) * 0 1 2 3 4 5 6 7 8 9 < = > ? @ A-Z ^ _ a-z ~Note what is absent: no space, no hyphen, no comma, no plus, no slash, no colon, no quote or bracket characters of any kind.
- T-3
In addition, a fixed allowlist of emoji code points is permitted. It is an explicit list of single code points in the U+1F004 to U+1FAD6 range, not a general Unicode category test. Skin-tone modifiers, variation selectors and zero-width-joiner sequences are not in the list, so a composed emoji such as a flag or a family sequence fails T-2.
- T-4
Tickers are case-insensitive. The ticker is lowercased before any comparison, so
KEVIN,kevinandKeViNare one and the same token. - T-5
Before matching, the ticker string is re-decoded from Latin-1 to UTF-8 where that succeeds. This repairs tickers that were mis-decoded upstream and is part of how emoji tickers survive the pipeline.
- T-6
Each token also carries a tick hash: the SHA3-256 (NIST SHA3, not Keccak) hex digest of the lowercased ticker. Indexers use it as a stable key for tickers containing characters that are awkward in a database column.
- Max ticker length5Unicode code points
- Case handlinglowercasedbefore every comparison
- Tick hashSHA3-256of the lowercased ticker
BRC-20 tickers are exactly 4 bytes. SRC-20 tickers are 1 to 5 code points from a restricted character set. A valid BRC-20 ticker is usually a valid SRC-20 ticker, but the reverse frequently is not, and a 5-character SRC-20 ticker has no BRC-20 equivalent.
8. Numeric rules
- N-1
The value of
max,limandamtmust satisfy0 ≤ value ≤ 18446744073709551615, that is, the unsigned 64-bit maximum. A value outside the range, or a NaN, excludes the transaction. - N-2
Numeric text must match
^[0-9]*(\.[0-9]*)?$. A leading sign, a thousands separator, whitespace, or any other character fails the match and sets the field to null with an invalid-number status. - N-3
maxandlimare truncated to integers, rounding down. A deploy declaring"lim": "1000.9"deploys a mint limit of 1000. This truncation is applied tomaxandlimonly. - N-4
amtis not truncated. It keeps its fractional part and is checked against the token's declared decimals instead. This asymmetry is real and is a common source of implementation divergence. - N-5
decmust match^[0-9]+$and lie in 0 to 18 inclusive. A value outside that range, or non-integer text, setsdecto null with an invalid-decimal status. When a DEPLOY omitsdec, the token's decimals default to 18. - N-6
Below height 833000 only, a numeric supplied as a string had every character that was not a digit or a dot removed before parsing.
"21,000,000"became21000000, and"1 000"became1000. At and above 833000 the string is parsed strictly and a malformed value excludes the transaction. Historical blocks must be replayed with the old behaviour. - N-7
For MINT and TRANSFER, the number of decimal places in
amtafter normalisation must not exceed the token'sdec. If it does, the operation is invalid with an invalid-decimal status. Trailing zeros do not count, because the value is normalised first:1.500against a token withdecof 1 is fine, because it normalises to1.5.
- Numeric ceiling18446744073709551615264 minus 1, for max, lim and amt
- Decimals range0 – 18default 18 when omitted
- RoundingROUND_DOWNmax and lim only, never amt
BRC-20 defaults dec to 18 as well, but its ceiling is uint64_max expressed differently and its reference indexers reject fractional max/lim outright rather than truncating them. If you port a BRC-20 validator to SRC-20 without changing the rounding behaviour, deploys with fractional limits will disagree.
9. Operations
Three operations are recognised. In each table, "required" means the key must be present for the payload to be considered that operation's shape at all.
9.1 DEPLOY
Creates a token. Required key set: op, tick, max, lim.
| Field | Required | Type | Rules |
|---|---|---|---|
p | Yes | string | Lowercased must equal src-20. See P-3. |
op | Yes | string | Uppercased must equal DEPLOY. |
tick | Yes | string | Rules T-1 to T-5. |
max | Yes | string or number | Total supply. Truncated down to an integer (N-3), range N-1. |
lim | Yes | string or number | Maximum per single mint. Truncated down (N-3), range N-1. |
dec | No | string or number | Decimal places, 0 to 18. Defaults to 18 when absent (N-5). |
desc | No | string | Stored as token metadata. No protocol effect. |
x | No | string | Metadata: X / Twitter handle. No protocol effect. |
web | No | string | Metadata: website. No protocol effect. |
email | No | string | Metadata: contact email. No protocol effect. |
tg | No | string | Metadata: Telegram. No protocol effect. |
Metadata fields are written to a separate table and later deploys of the same ticker cannot overwrite them with nulls. They do not participate in validity.
9.2 MINT
Issues supply to the transaction's destination address. Required key set: op, tick, amt.
| Field | Required | Type | Rules |
|---|---|---|---|
p | Yes | string | Lowercased must equal src-20. |
op | Yes | string | Uppercased must equal MINT. |
tick | Yes | string | Must name an already deployed token (V-4). |
amt | Yes | string or number | Amount to mint. Not truncated (N-4). Decimal places checked against dec (N-7). May be clamped, see V-6 and V-7. |
The minted balance is credited to output 0's address, not to the sender. Minting to someone else is therefore expressed simply by paying output 0 to them.
9.3 TRANSFER
Moves balance from the sender to the destination. Required key set: op, tick, amt.
| Field | Required | Type | Rules |
|---|---|---|---|
p | Yes | string | Lowercased must equal src-20. |
op | Yes | string | Uppercased must equal TRANSFER. |
tick | Yes | string | Must name an already deployed token (V-4). |
amt | Yes | string or number | Amount to move. Never clamped. If the sender's balance is short, the whole transfer is invalid (V-9). |
The reference indexer contains a handle_bulk_transfer function and a BULK_XFER key set (op, tick, amt, destinations) that would airdrop an amount to every holder of another ticker. It is unreachable. Operation dispatch matches only DEPLOY, MINT and TRANSFER; every other value, BULK_XFER included, falls to the unsupported-operation branch and is marked invalid. Do not implement it, and do not broadcast it.
10. Validity and state transitions
Plate IV · Token lifecycle
10.1 DEPLOY
- V-1
A DEPLOY is valid when the ticker has no existing deploy and both
maxandlimare present and non-zero after processing. - V-2
First deploy wins. Any later DEPLOY of the same ticker is invalid with status
DE. There is no re-deploy, no transfer of deploy authority, and no way to changemax,limordecafterwards. - V-3
Within a single block, transactions are processed in block order, so the earlier transaction in the block takes the ticker.
10.2 MINT
- V-4
A MINT for a ticker with no deploy is invalid with status
ND. - V-5
If total minted is already greater than or equal to
max, the mint is invalid with statusOM. The token is finished. - V-6
If
amtexceeds the remaining supply, it is reduced to exactly the remaining supply and the mint remains VALID, with statusOMArecording the reduction. This is how the final mint of a token behaves: whoever mints last receives only the dust that is left, not nothing. - V-7
If
amtexceeds the effective per-mint limit, it is reduced to that limit and the mint remains VALID, with statusODL. The effective limit ismin(lim, max): a deploy whoselimis larger than itsmaxsilently behaves as thoughlimequalledmax. - V-8
Both clamps can apply to one mint, the remaining-supply clamp first and then the per-mint clamp. The credited amount is the one that survives both.
OMA and ODL are not failure statuses. In the reference implementation each status carries an is_invalid flag, and for these two it is false. A validator that treats "amount was reduced" as "operation rejected" will under-count supply and diverge from every other indexer. If you take one rule from this page, take this one.
10.3 TRANSFER
- V-9
A TRANSFER is valid only when the sender's running balance is greater than or equal to
amtat the moment the operation is processed. Otherwise it is invalid with statusBBand nothing moves. There is no partial transfer. - V-10
A TRANSFER for a ticker with no deploy is invalid with status
ND. - V-11
A self-transfer, where the creator and the destination are the same address, is handled explicitly and is valid if the balance check passes. It is a no-op on the balance.
- V-12
"Running balance" includes the effect of earlier valid operations in the same block. Two transfers of the full balance in one block cannot both succeed; the second sees a zero balance and fails with
BB.
11. Invalidity conditions
The reference indexer records a status code on every processed operation. The table is reproduced exactly, including which codes leave the operation valid.
| Code | Meaning | Applies to | Operation invalid? |
|---|---|---|---|
DE | Deploy already exists for this ticker | DEPLOY | Yes |
ND | No deploy exists for this ticker | MINT, TRANSFER | Yes |
OM | Over mint: total minted already at or above max | MINT | Yes |
NA | Missing or empty amount | MINT, TRANSFER | Yes |
OMA | Amount reduced to the remaining supply | MINT | No, still valid |
ODL | Amount reduced to the per-mint limit | MINT | No, still valid |
BB | Balance below the transfer amount | TRANSFER | Yes |
UO | Unsupported operation | any other op | Yes |
ID | Amount has more decimal places than the token's dec | MINT, TRANSFER | Yes |
NN | Numeric field failed its regex, or dec out of range | any | Yes |
11.1 Exclusion versus invalidity
These are different outcomes and the distinction matters when reconciling two indexers.
- Excluded
- The transaction never becomes an SRC-20 record at all. It is not listed, not counted, and carries no status. Exclusion happens when the carrier fails to decode, the prefix is missing, the JSON does not parse, scientific notation is present, the ticker fails T-1 to T-3, or a required numeric is out of range or unparseable.
- Invalid
- The transaction is recognised as an SRC-20 operation and recorded, but it does not change any balance. It appears in activity feeds with its status code. Everything in the table above with "Yes" in the last column is in this category.
- Valid
- The operation changes state. Note that
OMAandODLoperations are valid and do change state, just by less than the payload asked for.
11.2 Carrier-level exclusions
- X-1
Multisig-carried SRC-20 without keyburn set to 1 is not an SRC-20 transaction (E-2).
- X-2
A transaction whose declared length exceeds the data actually present fails decoding (E-6).
- X-3
A transaction carrying a qualifying P2WSH data output whose payload fails the prefix or length check is excluded, and the multisig branch is not tried (E-12).
- X-4
A Counterparty-encoded SRC-20 operation at or after height 796000 is ignored (see section 12).
12. The Counterparty dependency
SRC-20 is often described as "built on Counterparty". That was true for roughly 8000 blocks and has not been true since. The accurate account is:
- CP-1
From height 788041, SRC-20 operations were carried inside Counterparty asset issuances. For such a transaction to count, the indexer required a Counterparty asset id (
cpid) that begins withA, meaning a numeric asset rather than a named one, and a declared supply of 0. - CP-2
From height 793068, SRC-20 operations began appearing directly on Bitcoin with no Counterparty encoding at all.
- CP-3
From height 796000, Counterparty-encoded SRC-20 is ignored entirely. A transaction only counts from this height onward if it has no Counterparty asset id. In code, validity is
valid_cp_src20() or (is_src20() and not cpid), andvalid_cp_src20()itself requiresblock_index < 796000. - CP-4
An indexer built today still needs Counterparty, but only to replay history below 796000 and to index classic Stamps, which remain Counterparty assets. SRC-20 state from 796000 onward can be derived from Bitcoin alone.
"SRC-20 is encoded through the Counterparty data layer" describes blocks 788041 to 795999. For every block since, SRC-20 is a Bitcoin-native protocol that shares a data carrier with Bitcoin Stamps and needs no Counterparty parsing. Both halves of that sentence matter: the history is genuinely Counterparty, and the present genuinely is not.