Target chain: Pepecoin (see docs/DECISIONS.md D-002).
Status: draft for Martin's approval. No code written yet.
Every source citation below is from /home/martin/reference/omnicore
(OmniLayer/omnicore, master, commit 1c0ae8ae, describes as v0.11.0) unless stated.
The app is a sidecar: it runs beside a stock, unmodified pepecoind, reads blocks over
RPC/ZMQ, and maintains Omni state in its own database. It is not a fork of the node.
Three consequences of the move to Pepecoin that change the plan materially:
(a) There is no Omni history on Pepecoin, so we choose the start height. This is a large, underappreciated win. On Litecoin the indexer would have had to replay ~1.16 M blocks (2,010,500 → tip) before showing anything. On Pepecoin we declare an activation height at or near the current tip, and the indexer has essentially nothing to replay. Initial sync becomes seconds, not days — on a 5400 RPM HDD that is the difference between a usable dev loop and a painful one.
(b) There is no reference implementation, so there is no oracle.
Nothing can tell us our consensus engine is right. Mitigations, in priority order:
1. Implement GetConsensusHash() exactly (§5) so any second implementation — including a
future one of ours — can be diffed against us.
2. Port Omni Core's unit tests (src/omnicore/test/) and functional tests
(test/functional/omni_*.py) as executable test vectors. They run on regtest and need no
mainnet chain.
3. Regtest harness from milestone M0 onward, not bolted on at the end.
(c) We must define our own protocol constants. Covered in §7 as open decisions.
| Layer | Recommendation | Why |
|---|---|---|
| Node | stock pepecoind v1.1.0, txindex=1, ZMQ on |
already installed; never forked |
| Indexer | Python 3.12 (pinned) | Martin knows Python; the workload is small (§2.1). Hot loop can move to Rust later behind the same interface if measurement demands it — not before. |
| Database | SQLite + WAL | single writer, embedded, trivial backup (one file), good enough at this scale. Access goes through a repository layer so Postgres remains possible without touching engine code. |
| UI | local web app — FastAPI + server-rendered HTML + HTMX, minimal JS, charts via a small JS lib | runs headless, reachable from another machine on the LAN, no GUI toolkit or ARM64/x86 packaging problems. Desktop app buys nothing here. |
| Content viewer | separate origin/port, sandboxed iframe, strict CSP, no network | inscribed files are untrusted bytes from strangers. Detailed in §6. |
Pepecoin has 60-second blocks (verified in source, chainparams.cpp:89). Because we choose
the activation height, the indexer's backlog starts at zero and grows at 1,440 blocks/day.
Blocks are currently near-empty. Python handling 1,440 mostly-empty blocks per day is not a
performance question.
The one place Python could bite is a full historical rescan if we ever change the rules and must replay from activation. Trigger to revisit: if a full replay exceeds ~30 minutes, move the block-parse and payload-decode hot path to Rust. Not before.
pepecoind (system service, user: pepecoin)
| JSON-RPC 127.0.0.1:33873 (historical blocks, tx lookup, broadcast)
| ZMQ 127.0.0.1:28332/3/5 (hashblock, rawblock, rawtx -> live tip + mempool)
v
omni-indexer (systemd user service) -> omni.sqlite (state + undo journal)
v
omni-web (FastAPI, 127.0.0.1) -> browser
|
+-- content server on a SEPARATE port/origin, sandboxed, for inscribed files
Reorg note: Pepecoin v1.1.0 has no zmqpubsequence (verified on host), so the indexer
tracks previousblockhash itself and rolls back via the undo journal.
This is the most consequential thing I found, and it changes how inscriptions should be built.
Constants (verified in source code):
| Constant | Value | Source |
|---|---|---|
PACKET_SIZE |
31 (1 seq byte + 30 data bytes) | src/omnicore/parsing.h:19 |
MAX_PACKETS |
255 | src/omnicore/parsing.h:20 |
SP_STRING_FIELD_LEN |
256 → 255 usable chars per text field | src/omnicore/omnicore.h:48 |
MAX_OP_RETURN_RELAY (Pepecoin) |
83 → 80 usable bytes | pepecoin/src/script/standard.h:30 |
| Omni marker | 4 bytes prepended to Class C data | src/omnicore/encoding.cpp:91-93 |
| Payload header | 4 bytes (version u16 + type u16) | src/omnicore/createpayload.cpp:575-583 |
Derived payload capacity per transaction:
| Vehicle | Data bytes / tx | Arithmetic |
|---|---|---|
Class C OP_RETURN, type 200 |
72 | 80 − 4 marker − 4 header |
| Class B multisig, type 200 (anydata) | 7,646 | 255 × 30 − 4 header |
| Class B, type 50 text fields — Martin's v1 | 1,020 | 4 fields × 255 chars |
Type 200 anydata over Class B carries ~7.5× more data per transaction than the type-50
text-field trick used in v1. A 1 MB file goes from ~1,028 transactions down to ~137.
Rough cost per 1 MB on Pepecoin (assumption — needs confirmation against live relay policy):
encoding.cpp:37), those outputs are
spendable by the creator and can be swept later. Good UTXO hygiene, unlike burn-style
data outputs.⚠️ Must verify before relying on this: a ~14.7 KB transaction with ~128 multisig outputs
must still be standard under Pepecoin's MAX_STANDARD_TX_SIZE and output-count policy, and
miners must actually mine it. Test on regtest, then testnet. If it fails, fall back to fewer
packets per transaction — the format must make chunk size a parameter, not a constant.
"Port cost" is my estimate of engine work, not UI.
| Type | Name | In Omni Core | In OmniLite | Plan | Port cost |
|---|---|---|---|---|---|
| 0 | Simple send | ✅ | ✅ | M2 | S |
| 3 | Send to owners | ✅ | ✅ | M4 | M |
| 4 | Send all | ✅ | ✅ | M2 | S |
| 5 | Send non-fungible | ✅ | ✅ | M4 | M |
| 20 | DEx sell offer | ✅ | ✅ | M3 | M |
| 22 | DEx accept | ✅ | ✅ | M3 | M |
| 25 | MetaDEx trade | ✅ | ❌ | M3 | L |
| 26 | MetaDEx cancel-price | ✅ | ❌ | M3 | M |
| 27 | MetaDEx cancel-pair | ✅ | ❌ | M3 | M |
| 28 | MetaDEx cancel-ecosystem | ✅ | ❌ | M3 | M |
| 50 | Create property (fixed) | ✅ | ✅ | M2 | S |
| 51 | Create property (crowdsale) | ✅ | ✅ | M6 | L |
| 53 | Close crowdsale | ✅ | ✅ | M6 | S |
| 54 | Create property (managed) | ✅ | ✅ | M2 | S |
| 55 / 56 | Grant / revoke | ✅ | ✅ | M4 | M |
| 70 | Change issuer | ✅ | ✅ | M4 | S |
| 71/72/185/186 | Freezing | ✅ | ✅ | M6 | M |
| 73 / 74 | Add / remove delegate | ✅ | ❌ | M6 | M |
| 200 | AnyData | ✅ | ✅ | M5 | S |
| 201 | Set NFT data | ✅ | ✅ | M4 | M |
| 65533/65534/65535 | Deactivation / activation / alert | ✅ | ✅ | M2 | M |
Bold = the three things Martin explicitly asked for.
Property types (verified in source, omnicore.h:89-95): 1 indivisible, 2 divisible,
5 non-fungible, 65/66 replacing, 129/130 appending.
Payload, type 25 — 28 bytes, all big-endian (verified in source,
createpayload.cpp:486-508):
version u16
type u16 = 25
propertyIdForSale u32
amountForSale u64
propertyIdDesired u32
amountDesired u64
There is no "side" field, and that is the elegant part: a bid and an ask are the same
message with the pair reversed. Selling PEP-token A for token B is an ask in A/B and a bid
in B/A. The book is two-sided by construction. Price is the ratio
amountDesired / amountForSale, held as an exact rational — never a float, or we will
diverge from consensus.
This means MetaDEx gives Martin what he asked for: a genuine two-sided order book with token↔token pairs, on-chain matching, and partial fills. Charts come from indexed fills, since every fill has a block height.
⚠️ This is the most consensus-critical code in the project. Matching order, price-time priority, tie-breaking, partial fills and rounding must be bit-exact or our state silently diverges. It gets the heaviest test-vector coverage (§5).
nftdb.h:59-67: GetRange, AddRange, DeleteRange).nftdb.h:17-18): IssuerData = 'I' and HolderData = 'H' — the issuer and the current
holder can each write, without overwriting each other.propertyId, tokenStart, tokenEnd, an issuer flag, and data
truncated to 255 bytes (verified in source, createpayload.cpp:110).255 bytes is far too small for a file, which settles a design question: an NFT does not hold its artwork — it holds a pointer to it. The NFT's data slot references an inscription built from type-200 chunks. That is exactly the "creator binding" the Phase 4 spec called for, and it is also how we stop name spoofing: the binding is issuer-signed on-chain.
anydata over Class B, ~7,646 bytes/tx (§3).GetConsensusHash() is a single SHA-256 over pipe-delimited records in a defined order
(verified in source, consensushash.cpp:94-234):
| Section | Record format | Ordering |
|---|---|---|
| Balances | address\|propertyid\|balance\|selloffer_reserve\|accept_reserve\|metadex_reserve |
address lexicographic, then property id |
| DEx sell offers | txid\|address\|propertyid\|offeramount\|btcdesired\|minfee\|timelimit |
by txid |
| DEx accepts | matchedselloffertxid\|buyer\|acceptamount\|acceptamountremaining\|acceptblock |
matched txid, then buyer |
| MetaDEx trades | txid\|address\|propertyidforsale\|amountforsale\|propertyiddesired\|amountdesired\|amountremaining |
by txid |
| Crowdsales | propertyid\|propertyiddesired\|deadline\|usertokens\|issuertokens |
by property id |
| Property issuers | propertyid\|issueraddress |
by property id |
Empty balance records and the pending tally are skipped (consensushash.cpp:111). Getting
these skip rules and orderings wrong is the single easiest way to produce a subtly wrong hash,
so this gets dedicated tests.
u64); prices are exact rationals. No floats anywhere in the
engine. This is a hard rule, enforced by a lint check.src/omnicore/test/ and test/functional/omni_*.py.Inscribed bytes are untrusted input authored by strangers. Non-negotiable rules:
<iframe sandbox> with no allow-scripts and no allow-same-origin.default-src 'none' plus the single media type being shown; connect-src 'none'.Note for Martin: Omni's omni_getcurrentconsensushash is a ledger-agreement check, not a
content checksum — it cannot tell you a recovered JPEG is intact. The per-file SHA-256 in the
v2 header is what does that, and it is ours to design. (Recorded in docs/DECISIONS.md.)
omni). Ours must differ or
we collide with anything Omni-derived. Proposal: pepo. Martin's call.omnicore.cpp:81). We need a Pepecoin address with no known private key — deterministically
derived from a fixed string and documented so anyone can re-derive and verify it.| # | Deliverable | Proves |
|---|---|---|
| M0 | Block reader: RPC + ZMQ, regtest harness, schema skeleton, reorg rollback | we can read every block deterministically and survive a reorg |
| M1 | Payload codec: Class B encode/decode with deobfuscation, Class C, all listed types; round-trip property tests | bytes ↔ structs, provably |
| M2 | Core state: properties 50/54, balances, sends 0/4, activation messages, consensus hash | a real ledger that can be diffed |
| M3 | MetaDEx 25–28: two-sided book, exact-rational matching, partial fills, cancels, OHLCV | the exchange Martin asked for |
| M4 | NFTs: type 5 property, send 5, 201 issuer/holder data, grant/revoke, change issuer | NFT support |
| M5 | Inscriptions: v2 spec, chunked type-200 writer, v1 and v2 reader, sandboxed viewer | inscribe + view files |
| M6 | Web UI: Wallet, Tokens, NFTs, Exchange, Create, Inscriptions, Explorer, Settings | the product |
| M7 | Transaction building: fund/sign via node wallet or PSBT (app never holds keys), regtest → testnet → mainnet | safe broadcast |
Each milestone ends with tests green and a short note in docs/.
Sequencing note: M0–M2 are prerequisites for everything. M3, M4, M5 are independent of each other once M2 lands, so their order is Martin's preference. M7's mainnet step is the first time anything is broadcast, and is gated on explicit confirmation per the ground rules.
Decisions D-003 through D-006. All open questions from §7 are now closed.
| Project | Ribbit |
| Class C marker | rbit — 0x72 0x62 0x69 0x74 |
| Base token | RBIT, property 1, divisible (8 dp), uncapped |
| Base token origin | burn-to-mint, 1 PEP = 1 RBIT, permanently open, no premine |
| Activation height | set at launch, ~tip + 1,440 blocks |
| Ecosystems | both (main + test) |
| Spec + code | public |
| UI binding | 127.0.0.1 only |
| Signing | node wallet RPC (fundrawtransaction / signrawtransaction) |
| Type | Name | Milestone |
|---|---|---|
| 0 | Simple send | M2 |
| 3 | Send to owners | M4 |
| 4 | Send all | M2 |
| 5 | Send non-fungible | M4 |
| 20 / 22 | DEx offer / accept — extended to accept NFT ranges | M3 |
| 25 / 26 / 27 / 28 | MetaDEx trade + 3 cancels | M3 |
| 50 / 51 / 53 / 54 | Property: fixed / crowdsale / close crowdsale / managed | M2 (50, 54), M6 (51, 53) |
| 55 / 56 | Grant / revoke | M4 |
| 70 | Change issuer | M4 |
| 71 / 72 / 185 / 186 | Freezing | M6 |
| 73 / 74 | Delegates | M6 |
| 200 | AnyData — inscription payload | M5 |
| 201 | Set NFT data | M4 |
| 65533 / 65534 / 65535 | Deactivation / activation / alert | M2 |
| — | Burn-to-mint (Ribbit-specific, no Omni equivalent) | M2 |
Out of scope: type 2 restricted send, and types 10/11/12/15/31/40/52 (legacy or never implemented in Omni Core).
Deliberate divergences from Omni Core — each needs its own spec text and test vectors,
because no reference implementation exists for them:
1. Burn-to-mint — Exodus address gains a second meaning as a value sink.
2. NFTs on DEx — Omni Core rejects non-fungible properties on MetaDEx (tx.cpp:1653) and
offers no NFT trading path at all.
| # | Deliverable |
|---|---|
| M0 | Block reader (RPC + ZMQ), regtest harness, schema, reorg rollback |
| M1 | Payload codec: Class B (with deobfuscation) + Class C, all in-scope types, round-trip property tests |
| M2 | Core state: properties 50/54, balances, sends 0/4, burn-to-mint, activation messages, consensus hash |
| M3 | MetaDEx 25-28 two-sided book, exact-rational matching, partial fills, cancels, OHLCV; DEx 20/22 incl. NFT ranges |
| M4 | NFTs: type 5 property, send 5, 201 issuer/holder data, grant/revoke, change issuer, send-to-owners |
| M5 | Inscriptions: v2 spec, chunked type-200 writer, reader, sandboxed viewer |
| M6 | Crowdsales 51/53, freezing, delegates; web UI (Wallet, Tokens, NFTs, Exchange, Create, Inscriptions, Explorer, Settings) |
| M7 | Transaction building and broadcast; regtest → testnet → mainnet, launch activation height set |
After D-007 (no base token) and D-008 (no crowdsales).
| Project | Ribbit |
| Class C marker | rbit (0x72 0x62 0x69 0x74) |
| Base / quote token | none — not part of the protocol |
| Exodus-equivalent address | Class B marker only, exactly as in Omni. No value-sink role. |
| Property IDs 1, 2 | permanently reserved, unassigned. User properties start at 3. |
| Activation height | set at launch, ~tip + 1,440 blocks |
| Ecosystems | both (main + test) |
| Spec + code | public |
| UI | 127.0.0.1 only |
| Signing | node wallet RPC; app never holds keys |
| Type | Name | Milestone |
|---|---|---|
| 0 | Simple send | M2 |
| 3 | Send to owners | M4 |
| 4 | Send all | M2 |
| 5 | Send non-fungible | M4 |
| 20 / 22 | DEx offer / accept — extended for NFT ranges | M3 |
| 25 / 26 / 27 / 28 | MetaDEx trade + cancels | M3 |
| 50 | Create property (fixed) | M2 |
| 54 | Create property (managed) | M2 |
| 55 / 56 | Grant / revoke | M4 |
| 70 | Change issuer | M4 |
| 71 / 72 / 185 / 186 | Freezing | M6 |
| 73 / 74 | Delegates | M6 |
| 200 | AnyData — inscriptions | M5 |
| 201 | Set NFT data | M4 |
| 65533 / 65534 / 65535 | Deactivation / activation / alert | M2 |
Out of scope: 2 (restricted send), 10, 11, 12, 15, 31, 40, 51, 52, 53.
Only one divergence from Omni Core remains: NFT ranges on DEx (20/22). Omni rejects
non-fungible properties on MetaDEx (tx.cpp:1653) and has no NFT trading path. This is the
sole piece of consensus code with no reference implementation, and gets the heaviest bespoke
test coverage.
| # | Deliverable | Proves |
|---|---|---|
| M0 | Block reader (RPC + ZMQ), regtest harness, SQLite schema, reorg rollback | deterministic reads; a reorg actually rolls back |
| M1 | Payload codec: Class B (with deobfuscation) + Class C, every in-scope type, round-trip property tests | bytes ↔ structs, provably |
| M2 | Core state: 50, 54, balances, 0, 4, activation messages, consensus hash | a ledger that can be diffed |
| M3 | MetaDEx 25-28: two-sided book, exact-rational matching, partial fills, cancels, OHLCV. DEx 20/22 incl. NFT ranges | the exchange |
| M4 | NFTs: type 5, send 5, 201 issuer/holder slots, 55/56, 70, send-to-owners | NFT support |
| M5 | Inscriptions: v2 spec, chunked type-200 writer, reader, sandboxed viewer | inscribe + view files |
| M6 | Freezing, delegates, web UI (Wallet, Tokens, NFTs, Exchange, Create, Inscriptions, Explorer, Settings) | the product |
| M7 | Transaction building and broadcast; regtest → testnet → mainnet | safe launch |
u64; all prices exact rationals. Lint-enforced.Class B payloads are always a multiple of 30 bytes. The final packet is
NUL-padded and Omni does not strip the padding (omnicore.cpp:1263); Ribbit
matches that, because matching is a consensus requirement.
Consequence for the v2 format (M5): a type-200 chunk can carry up to 29 trailing NUL bytes the sender never wrote. The v2 header's content-length field is therefore load-bearing, not a convenience — without it, any chunk whose real content ends in NUL is unrecoverable. Stripping padding in the codec was considered and rejected: it would silently destroy genuine trailing NULs, which is a worse failure than carrying padding the header can account for.
Verified capacity (by test, not arithmetic):
| Vehicle | Data per transaction |
|---|---|
| Class C, type 200 | 72 bytes |
| Class B, type 200 | 7,646 bytes, in 128 multisig outputs |