What is Pyde
What Pyde is, what it removes, and how it operates — so the spec chapters land on intuition, not acronyms.
Pyde, in one paragraph
Pyde is a Layer 1 blockchain network. Post-quantum from day one, MEV-resistant by construction, and cross-chain by certificate. It removes the two trust problems crypto-native businesses cannot solve today — front-running that taxes users 1–5% per trade, and bridges that have lost over $3 billion since 2021 — both fixed at the protocol level, not by policy. It is the chain you build on when execution has to be fair, finality has to mean final, and verification has to outlive the cryptography it was built with.
What Pyde removes
Two structural taxes vanish at the protocol layer:
Front-running. Transactions are encrypted in the mempool until after the network commits to their order. No validator, sequencer, or searcher can read a transaction before its place in line is locked. Sandwich attacks, JIT liquidity, and proposer extraction are not auctioned or mitigated — they are structurally impossible. Users keep the price they signed.
Bridge custody risk. Any chain — Ethereum, Solana, a parachain, an L1 not built yet — can verify a Pyde transaction directly using its FALCON-signed finality certificate. No multisig, no custodian, no third party to trust. Value crossing chains never sits in a contract someone else controls.
What makes Pyde different
Three properties ship as defaults at genesis. No production chain combines them today.
Post-quantum cryptography. FALCON-512 signatures, Kyber-768 (ML-KEM) threshold encryption, Poseidon2 + Blake3 hybrid hashing. No pre-quantum primitive on any consensus or account path. The network still settles after a working quantum computer breaks what today's chains run on.
Structural MEV resistance. Threshold-encrypted mempool with commit-before-reveal ordering. Fairness is not enforced by policy or auctioned to the highest bidder — it is a property of the protocol.
Portable cross-chain certificate. A Pyde finality certificate verifies anywhere it lands. Cross-chain interop is cryptography, not a multisig.
Other chains can add any one of these. None can add all three without a hard fork that breaks every deployed app. Pyde ships them as one architecture, day one.
What's in the name
Pyde (pronounced pied, rhymes with tide). The name carries two senses at once, and both are intentional.
The older sense is tide. A tide is an inescapable, continuous current — it does not ask permission, it does not stop for the night, it does not wait for any single drop to arrive before moving the next. Pyde the network was designed to feel like that. The throughput of a blockchain is rarely about how fast one transaction can land; it is about whether the assembly line ever empties. Pyde's assembly line does not empty. The protocol commits in waves — not poetic waves, literal ones, the way water commits to shore — and the rest state of the system is motion. The factory metaphor that runs through this book is the tide made mechanical.
The surface sense is pied — a casual, phonetic spelling. The name was picked to sit quietly: short, easy to say, easy to type in a hurry, distinctive enough to search for. pyde.network, @pydenet, t.me/pydenet — the rhythm matters when you will type it ten thousand times. It was picked knowing it would mostly be written lowercase, in DMs, by people whose hands are tired.
There is no third sense. No hidden Greek letter, no acronym backing it out, no "Programmable Yield Decentralization Engine" trying to sneak in through the back door. The name is just the name.
The mark
The mark is based on atomic structure — a nucleus and its orbital.
The vertical form is the core. Dense, gravitational, everything pulls toward it. Pyde's architecture is monolithic: consensus and execution unified in one gravitational center.
The circle to its right is in orbit. Independent, in motion, but bound to the core by an invisible force. External chains, bridges, and light clients orbit Pyde freely — verified, not trusted.
The two are separate on purpose. Related but sovereign. The same way a Pyde finality certificate can prove itself anywhere without depending on the chain it came from.
The core is wide at the poles and compressed at the center. Finality under pressure. Stress-tested and held.
No sharp edges. No network imagery. Nothing decorative. The mark looks like a physical law, not a trend.
Grayscale only. Works as a favicon, on a sticker, in metal, as a watermark. Full brand rules live in the Brand Reference.
The mark is the architecture
The atomic reading is not visual flavour. It is the design.
Pyde is the core. Consensus and execution live in one system. State lives where transactions are ordered. The DAG, the JMT, the wasmtime executor — one process, one gravitational well. Most modern chains split these into layers. Pyde does not.
Verification is the binding force. Nothing orbiting the core is trusted. Light clients, bridges, foreign chains, wallets running local previews — they all bind through cryptographic proof. FALCON-signed finality certificates, JMT inclusion proofs, threshold decryption shares. The orbits are mathematical, not political.
Things orbit without merging. A Pyde finality certificate can travel to Ethereum and prove itself there without phoning home. A parachain has its own sub-orbit inside Pyde's well — its own validators, its own state subtree — and stays sovereign. Sovereignty without isolation.
Compression is BFT under pressure. Wave commits run under adversarial conditions. The 85-of-128 quorum, the slashing schedule, the structural MEV resistance — they exist so the core holds when squeezed. Stress-tested.
One core. Many orbits. Bound by physics, not by trust.
How Pyde operates
Most blockchain explanations start with cryptography and end with consensus, leaving the reader holding a bag of acronyms. We are going to do this differently.
Pyde is a factory. Goods (transactions) arrive at the loading dock from outside. They are sorted, lifted onto a continuously-moving assembly line, and arranged by a series of robotic arms working in parallel. Every few hundred milliseconds, the great press slams down and locks a batch as final — the slam you feel when the factory floor shakes is a wave commit. After the slam, the audit ledger is stamped, exhaust rises from the chimney (eviction, pruning), a receipt is sent out the front door, and the line keeps moving without ever stopping.
The continuous rotation is the throughput. Pyde is not a fast database; it is a deep pipeline.
The Pyde cycle, ~2-3 times a second on commodity hardware. Each pass is one wave commit.
The eleven stages
The full cycle, end-to-end, from a user's keypress to a receipt landing back in their wallet, is eleven stages. Five are happening to your transaction. The other six are happening to other people's transactions concurrently, on the same factory floor, because the line never stops.
Stage 0 — Workshop floor (the user)
A user opens a wallet and asks it to send 100 PYDE to alice.pyde. The wallet quietly does five things before showing a "Sign" button: it resolves the recipient name via JSON-RPC, fetches the sender's account state, fetches any relevant contract bytecode, runs the transaction locally inside a wasmtime sandbox embedded in the wallet itself (Tier 1 client-side preview, see Chapter 17 §17.4b), and shows the user a preview: "This tx will send 100 PYDE, cost ~21,000 gas, leave your balance at 900 PYDE." Only then does the user sign with their FALCON-512 key, and only then does the tx leave their machine.
If the user opted for confidentiality, the wallet also encrypts the payload with the current epoch's threshold pubkey before signing — the recipient and amount become opaque ciphertext that no validator can read until the wave commits.
Stage 1 — Loading dock (RPC ingress)
The transaction lands at any RPC node. RPC nodes are stateless ingress: they hold no validator key, sign nothing, and have no consensus role. They parse the JSON, do a shape check, rate-limit, return the tx hash to the wallet synchronously, and then shovel the transaction into the libp2p Gossipsub mempool topic. From the wallet's perspective the trip is done. In reality it has just begun.
Stage 2 — Sorting room (mempool)
Every node — and especially every committee validator — runs a validation pipeline on each incoming tx: signature verify (FALCON-512, batchable), nonce window check (the tx's nonce must be within sixteen of the sender's last committed nonce), balance sufficiency, gas-limit cap, attribute coherence. Passes go into the local mempool DashMap, organised by gas-price descending. Failures are dropped and the gossip score of the peer that sent it is docked. Encrypted transactions land here too: the envelope is validated, but the payload stays sealed until threshold decryption fires at commit.
Stage 3 — Assembly-line dispatch (batches and vertices)
Inside each of the 128 committee members for this epoch, two things happen continuously. First, every hundred milliseconds or so, the member packs the highest-fee transactions into a Batch (~50-200 txs, ~4 MB cap) and broadcasts it on the /pyde/batches/1.0.0 topic. Second, every round (~150-500 ms, structurally paced — see below), the member emits a Vertex that references ≥85 parent vertices from the previous round, references whichever batches it wants to include, carries piggybacked decryption shares for any encrypted transactions in the subdag, contributes a VRF beacon share, attests to the previous anchor, and is signed by the member's epoch key. Vertices broadcast on /pyde/dag/1.0.0 and form the next floor of the DAG.
The round advances when the member has seen ≥85 vertices from the current round, not when its own timer fires. This is the structural-pacing trick that makes Mysticeti elegant: the floor speed is the median peer speed, not the slowest peer's speed. A single laggard cannot stall the line.
Stage 4 — The foreman picks the lead (anchor selection)
Every K rounds (typically K=3), an anchor is picked, deterministically and verifiably, by all 128 members simultaneously:
anchor_validator_id = VRF(beacon_combined, round, prev_state_root) mod 128
The beacon is the XOR of the prior round's VRF shares (public randomness). The previous state root locks anchor selection to canonical history, so an adversary who reorders the DAG cannot retroactively choose a more favourable anchor. Mod 128 picks which member's vertex at this round wears the crown. Every honest member computes the same answer.
Stage 5 — The press slams (wave commit) 💥
Once the anchor has accumulated ≥85 attestations from later-round vertices (other members' vertices that reach the anchor transitively through parent links), the commit threshold trips. The press comes down.
What the slam does, in three lines:
- BFS subdag walk — starting at the anchor, walk every parent reference recursively. The set of touched vertices is the subdag being committed.
- Canonical sort — order the subdag by (round, author_id, batch_list_order). Every honest member produces the same order.
- Dedupe + flatten — same transaction may appear in multiple batches across multiple members; keep the first appearance. The result is the wave's
ordered_list, a fully deterministic transaction sequence.
That sequence is what gets executed. Before the slam the DAG is ambiguous; after the slam it is fixed. See Chapter 6 §5b–5c for round-vs-wave terminology, missing-vertex handling, and the 5-skip recovery walkthrough.
Stage 6 — Unboxing the sealed crates (threshold decryption)
Encrypted transactions in the ordered list were opaque until now. Each was sealed with a one-time symmetric key encrypted under the epoch's threshold pubkey. Every vertex committed in the wave piggybacked a decryption share for each encrypted tx in the committable subdag. By commit time, ≥85 shares per encrypted tx are already in hand.
For each encrypted transaction: Lagrange interpolation across the shares recovers the decryption key, the payload is decrypted in-memory, and the now-revealed transaction is re-validated (nonce, balance) one final time before execution. If the decrypted transaction is invalid, it is dropped — but the sender still pays a small gas bond from their plaintext balance (anti-spam). The order-then-decrypt design is what gives Pyde its MEV protection: validators cannot front-run, sandwich, or censor based on transaction content, because they could not read it when they ordered it.
Stage 7 — Robotic arms picking and ordering (execution)
The wave's ordered_list enters the Block-STM scheduler. First, the scheduler walks every tx's declared access list and unions every (addr, slot) pair into a single prefetch set, then issues one batched state_cf.multi_get (PIP-3) to warm the dashmap (PIP-4) before any worker starts — the access list is a prefetch hint only, never used to partition the wave or affect correctness. Then every tx runs optimistically in parallel on a rayon pool, reading + writing through a multi-version concurrency control (MVCC) layer addressed by (tx_index, attempt). The validate pass checks every read against the canonical tx_index order; reads that have since been invalidated by a lower-tx_index write abort the tx, drop its writes, and re-incarnate it at attempt+1. The cycle repeats until every tx is validated — fixpoint — then the highest-tx_index's last write per slot is flushed to the JMT. Aptos's measured production numbers (10-30K real-world TPS) anchor Pyde's v1 throughput target.
For each transaction, the dispatch looks at the type. Native transactions (Transfer, ValidatorRegister, Stake, Unstake) skip wasmtime entirely — direct calls into native handlers, ~21K gas, no WASM cost. Contract calls and contract deploys enter the wasmtime path: load (or fetch and Cranelift-compile) the contract module from state, instantiate it with a 64 MB linear-memory cap and gas_limit of fuel, invoke the entrypoint, run host functions (sload, sstore, sdelete, log, cross_call) through a per-transaction overlay that snapshots reads and isolates writes. Success merges the overlay into the wave overlay; trap discards it; either way the gas actually consumed is deducted (no refunds in v1, see Chapter 10 §10.1). Cross-contract calls nest overlays recursively so a failed sub-call rolls back cleanly without touching the caller's state.
Stage 8 — Inventory audit (state root computation)
After execution, the wave overlay holds every write and every emitted event. Now the audit stamp goes on. Each (slot_hash, value) write lands in two places: the state_cf flat table (live state, O(1) reads later) and the jmt_cf versioned tree (proofs and state root). JMT internal nodes touched by this wave are recomputed with dual hashes — Blake3 for fast native verification, Poseidon2 for future ZK light clients (see Chapter 4 §4.1b). Events land in three more column families — events_cf (primary, ordered by wave) plus events_by_topic_cf and events_by_contract_cf (indexes for fast filtering) — and the wave commit record carries an events_root (Blake3 Merkle tree over canonical-ordered events) plus a 256-byte events_bloom so light clients can verify event inclusion identically to how they verify state. The new state root, the events root + bloom, the wave commit record, the receipts, and the tx-to-wave mapping all land in a single atomic RocksDB WriteBatch. Either the entire wave commits or none of it does. There is no such thing as a half-committed wave.
Stage 9 — Exhaust from the chimney (eviction and pruning) 💨
The DashMap write-back cache layer holds writes from recent waves in memory; reads against hot accounts are near-free here. On every wave boundary, the cache is flushed and LRU eviction trims it back under its size cap. Hot accounts (token contracts, popular pools) stay resident; cold accounts get evicted and next access pays one disk read against state_cf. Pruning policy varies by node tier: archive nodes keep everything; full nodes drop state-tree versions older than ninety days; committee validators keep thirty days. The mempool drops every transaction that just committed and every transaction whose nonce window has now closed.
The plume rising from the chimney is the eviction. The exhaust trailing it is the pruning. The factory shrinks back to a clean working volume ready for the next round.
Stage 10 — Receipt out the front door (back to the user)
The wallet has been holding a WebSocket subscription on the transaction hash since Stage 1. The moment Stage 8's WriteBatch lands, the RPC layer pushes:
{
"tx_hash": "0x...",
"status": "success",
"wave_id": 1234567,
"gas_used": 21000,
"events": [{ "topic": "Transfer", "to": "0xabc...", "amount": "100" }],
"state_root": "0x..."
}
The wallet updates the user's view: "Transferred 100 PYDE to alice.pyde. Confirmed." For light clients (mobile wallets, browser dApps), the same wave commits as a 200-byte header signed by the committee threshold — the light client verifies the threshold signature against the committee pubkeys it already trusts and has now verified the entire wave's integrity without downloading a single transaction. See Chapter 17 §17.3 for the SDK surface and Companion: State Sync for the light-client model.
Stage 11 — The eternal rotation 🔁
Everything you have just read is happening in parallel for different waves. While Stage 7's arms execute wave 1,234,567, round R+1 has already advanced, decryption shares for round R+5's encrypted transactions are piggybacking through the gossip layer, the next anchor is already known by VRF, the mempool is already sorting transactions that will land in wave 1,234,568, and somebody's wallet on the other side of the world is running a Tier-1 preview for a transaction that does not yet exist. The pipeline is deep. The conveyor belts overlap. The press slams roughly twice a second.
The continuous rotation is the throughput. No single transaction is faster than on a slower chain — but the assembly line never empties.
What the metaphor catches that the spec sometimes loses
- Pipelining is everything. Stages 1–11 run concurrently for different waves. No stage waits for another stage to finish.
- The slam is real. Wave commit is a discrete moment that locks order. Before the slam the DAG is ambiguous; after the slam it is canonical.
- Exhaust is not waste — it is necessary. Eviction and pruning are first-class. Without them the factory clogs on its own inventory.
- The user only sees the loading dock and the receipt window. Everything in between is hidden machinery. The wallet's job is to make the slam feel like an instant click.
Where to read next
If you want the detailed mechanics of any stage:
- Stages 1–2 (ingress, mempool): Chapter 12 — Networking
- Stages 3–5 (DAG, anchor, commit): Chapter 6 — Consensus
- Stage 6 (threshold decryption): Chapter 6 §11 and Chapter 9 — MEV Protection
- Stage 7 (execution, Block-STM, per-tx overlay): Chapter 3 — Execution Layer
- Stage 8 (state model, JMT, dual hash): Chapter 4 — State Model
- Stage 9 (eviction, pruning): Chapter 4 §4.1b and Companion: State Sync
- Stage 10 (wallets, SDKs, RPC): Chapter 17 — Developer Tools
And if you want the deep historical narrative on how Pyde arrived at this design: The Pivot.
Why Pyde
Pyde is the chain you'd build if you started today — with the post-quantum cryptography NIST standardized in 2024, the Mysticeti consensus Mysten Labs proved in production in 2024, and the WebAssembly runtime Fastly and Microsoft ship at scale. Nothing here is exotic. The combination is.
Most production chains had to pick which properties to ship first and migrate the rest later. Pyde was built greenfield to ship all of them at once — which is why this page is organized by the people who'll feel the difference, not by the layers of the stack.
- For businesses — settlement that holds through the next cryptographic generation, no invisible tax on customer trades, predictable fees, verifiable receipts.
- For developers — familiar tools, real extensibility (build your own parachain), honest performance numbers, production-grade runtime.
- For users — no sandwich attacks, sub-second confirmations, see-what-you-sign wallets, quantum-proof funds.
If you want the technical depth behind any of the claims below, the chapters that follow are where the protocol-level evidence lives. The Whitepaper is the single-document reference; the chapters break it apart at the granularity of a working engineer.
For businesses
The properties production needs, defaulted from day one — not retrofitted to a chain holding live value.
Quantum-proof from genesis. Every other production Layer 1 — Bitcoin, Ethereum, Solana, Cardano, Sui, Aptos — secures account paths with classical cryptography that breaks the day a cryptographically-relevant quantum computer exists. NIST standardized the post-quantum replacements in 2024, but retrofitting them into a chain holding trillions in value is a multi-year coordination problem none have solved. Pyde uses FALCON-512 signatures, Kyber-768 encryption, and Poseidon2 hashing from block zero. Long-tail contracts — insurance policies, multi-year escrows, intellectual-property registries, legal records — remain cryptographically valid into the quantum era, with no migration to budget for.
MEV protection at the protocol layer, not via a trusted relayer. Other chains' answer to MEV is a third-party relayer — Flashbots on Ethereum, Jito on Solana — a service businesses must opt into and trust to behave. On Pyde, MEV-sensitive transactions are encrypted under a threshold key held jointly by 128 validators. The committee commits to a canonical order before any decryption share is released. The information asymmetry MEV needs to exist on simply doesn't. Your users keep what they paid; you don't owe anyone a trust assumption you can't independently verify.
Settlement in ~500 milliseconds. Mysticeti-style consensus reaches finality at roughly half a second median. Your customer's payment confirms before their hand leaves the mouse. On Ethereum that's 12 seconds — long enough for users to refresh, retry, or abandon. Checkout abandonment drops. Cash flow accelerates. Customer-support tickets stop being about "I paid but it didn't go through."
Predictable fees under load. EIP-1559 base fee. No tips. No MEV race driving gas competition during popular drops. When an NFT mints on Ethereum, gas can 10x in 30 seconds and your unit economics break. Pyde's structural absence of MEV competition combined with the no-tip fee model keeps cost predictable. You can quote your operations team a number that's true tomorrow.
Cryptographic receipts your auditors can verify offline.
Every committed transaction comes with a HardFinalityCert — a FALCON quorum certificate signed by 85 of 128 independent validators. Your compliance team doesn't trust the chain; they verify the math. The certificate is portable: any external system that can verify FALCON signatures can verify a Pyde commitment, on or off chain.
Run your own validator on a normal machine. Most high-throughput chains have made validation a premium-hosting business — Solana production validators run on 12+ cores and 256+ GB RAM, costing $20K+/month. A Pyde committee validator runs on 8 cores, 16 GB RAM, and a 500 Mbps – 1 Gbps connection. Your enterprise can verify Pyde independently without an infrastructure budget that defeats the purpose of running your own node.
For developers
Familiar tools. Honest performance. Real extensibility.
Write in any language that compiles to WebAssembly.
Rust, AssemblyScript, Go (TinyGo), C, C++, Zig — anything that targets wasm32. No Solidity to learn. No Move. No Cairo. No proprietary VM to internalize. Your team uses the stack they already know. The otigen developer toolchain handles scaffolding, build, ABI generation, and deployment regardless of source language.
Build your own parachain — a chain inside the chain.
This is what no other production Layer 1 offers without auctioned slots or central gatekeeping. Pyde's parachain framework lets you author a WASM module that runs as its own execution environment with its own state, validated by Pyde's committee. Want to ship a privacy-first application chain? A custom VM optimized for your domain? A confidential-vote chain? An oracle network? A gaming-specific subchain with its own throughput profile? Build a parachain. Pyde validators stake PYDE to run yours; they earn the fees you set. Your parachain inherits Pyde's HardFinalityCert, cross-parachain messaging, security model, and threshold-encryption infrastructure for free.
This is how dev communities get built around real innovation — you bring the logic, Pyde brings the substrate. No auction. No bidding war. No "we'll consider your team in the next batch."
Cross-parachain composability through one cryptographic primitive.
Pyde's HardFinalityCert is portable. Any chain that can verify FALCON signatures can verify a Pyde commit. Your parachain talks to other parachains, to Pyde's main chain, and (post-mainnet) to external chains through a single signed certificate. No bridge multisig to trust. No oracle latency to budget for. No fragile relayer in the middle.
Hybrid parallel execution. Pyde's execution layer is a uniform Block-STM scheduler: every transaction runs optimistically in parallel through an MVCC layer, conflicts are caught at validation, and losers re-execute. Wallets can attach an access list per tx as an optional prefetch hint — the chain warms its cache (PIP-3 multiget) before workers start. The hybrid Solana-+-Aptos framing was the older intermediate proposal; v1 ships uniform Block-STM with access lists as a prefetch optimisation only.
Performance numbers you can defend in production. The v1 mainnet throughput target is established by a multi-region harness with real network latency before any number is published. We publish only what the harness measures under sustained, production-realistic conditions — never lab extrapolations or microbenchmark peaks. If we promise it, you can build on it.
Runtime that already powers production at scale. wasmtime + Cranelift AOT — the same WebAssembly runtime Fastly serves edge functions on, Microsoft ships in Hyperlight, Shopify uses for app extensions, and the Bytecode Alliance maintains with 50+ corporate contributors. Not a homegrown VM with a 1.0 release ahead of it.
Native session keys and programmable accounts are planned. v1 ships native multisig (up to 16 signers). v2 ships scoped session keys and programmable accounts at the protocol layer — not retrofitted like Ethereum's ERC-4337. Your dApp gets bounded, revocable delegation as a first-class primitive: gaming sessions, AI-agent delegation, recurring payments, all without a wallet popup per action. v1 reserves the protocol surface so contracts written today survive the v2 upgrade unchanged.
16 concurrent transactions per account. Most chains lock you to one in-flight transaction per account. If one stalls, your queue stalls. Pyde maintains a 16-slot nonce window — submit up to 16 transactions concurrently per account, out of order within the window. Wallet UX, exchange settlement, and high-frequency dApps all benefit.
For users
Your transactions. Your funds. Kept yours.
No more sandwich attacks. On most chains, bots watch the mempool and trade against your transaction — buying before your swap to push the price, selling after for profit. You lose 1-5% of trade value to actors you don't know exist. Pyde encrypts your transaction's content under a threshold key held jointly by 128 validators. The order is committed before any decryption share is released. The information asymmetry MEV needs cannot structurally exist. You keep the price you signed.
Confirmations in half a second. ~500ms to finality at the median. Your wallet shows the result immediately. No "12 seconds and counting" spinner. No refresh-and-pray.
Predictable fees. Your $5 swap costs $5 in fees. Not $5 plus $80 of MEV extraction. Not $50 because someone launched an NFT mint at the same moment.
See exactly what you're signing — before you sign. Pyde wallets run your transaction locally first (deterministic wasmtime simulation) and show every state change — balances moved, contracts called, events emitted — before asking for your signature. No "approve this transaction" leap of faith. No surprise approval draining your wallet a week later.
Quantum-proof funds. Your funds stay yours even when quantum hardware can break the cryptography securing Bitcoin and Ethereum. Built in from genesis, not a retrofit you have to wait for.
Cross-chain by certificate, not by trusted bridge. Custodial bridge multisigs have lost over $3B since 2021. Pyde's cross-chain finality is verified cryptographically by 85+ validator signatures — math your wallet checks, not a multisig you have to trust.
Where to go from here
- Read the Whitepaper for the single-document technical reference (downloadable PDF at pyde.network/whitepaper.pdf).
- Read How Pyde Works for the high-level visual explainer.
- Start at Chapter 1 — Introduction for the technical entry point into the chapters.
- Browse the Companion specs for the depth-first treatment of each subsystem.
Get Started
Pick the path that fits you.
I want to build on Pyde →
You're a developer or technical founder. You want to write a contract, spin up a local devnet, deploy something, integrate with the chain. Start here for the toolchain, the host-function ABI, language-specific examples, and the local-devnet flow.
I want to use Pyde →
You're an end user. You want to hold PYDE, send transactions, run a node, or follow the project's mainnet path. Start here for the wallet story, post-quantum guarantees in plain English, what makes Pyde different from other L1s, and what's available pre-mainnet vs post-mainnet.
Not sure which path? If you're going to type cargo build at any
point, take the developer track. If you only ever interact with Pyde
through a wallet or a dApp, the user track is where you belong.
Both paths converge at the same set of canonical specs in the book — chapters 1–20 and the companion files — when you need to go deep on a specific topic.
Get Started — for Developers
You're here because you want to build something on Pyde. This page is the on-ramp: enough orientation to land you on the right specs, without reproducing them.
What you can build
Pyde supports two contract surfaces:
- Smart contracts — sandboxed WASM modules deployed to the chain. Standard L1 contract development; read Chapter 3 — Execution Layer for the runtime model.
- Parachains — permissionless side-runtimes that share Pyde's finality and validator set, with their own state subtree and an extended ABI for cross-chain messaging + threshold cryptography. Read Chapter 13 — Parachains.
Both compile to WebAssembly. Pyde executes them via wasmtime + Cranelift AOT — deterministic feature subset, per-tx overlay isolation, fuel-metered gas.
What language?
Whatever targets wasm32. Pyde doesn't ship per-language SDKs;
authors compile their .wasm themselves and use the otigen
toolchain to package + deploy it. First-class examples ship for:
- Rust —
cargo build --target wasm32-unknown-unknown --release - AssemblyScript —
npx asc contract.ts -o contract.wasm - Go (TinyGo) —
tinygo build -target wasm-unknown -o contract.wasm - C / C++ —
clang --target=wasm32 -nostdlib -Wl,--no-entry
The chain only sees the bytes. Pick what fits your team.
The five things to read
In order:
- Chapter 1 — Introduction — 10-minute orientation. Why Pyde exists, what it's not.
- Chapter 3 — Execution Layer — the runtime, the per-tx overlay, the determinism contract.
- Host Function ABI v1.0 —
every
pyde::*function your WASM can import. Signatures, semantics, gas costs, error codes. This is the contract the chain stands on. - Chapter 5 — Otigen Toolchain
— how
otigenbuilds, tests, deploys, and manages wallets. - Otigen Binary Spec v1.0 — the CLI surface. Every command, every flag.
- Otigen Test Spec v1.0 — the contract-behaviour test framework (Foundry-grade, TOML). Read once you have a working contract.
Bookmark these. The rest of the book (state model, gas, accounts, consensus, networking, parachains, slashing, governance) you read on demand.
The minimum loop (once mainnet ships)
# 1. Scaffold a project
otigen init my-token --lang rust
# 2. Edit src/lib.rs + otigen.toml; write tests/contract.test.toml
# 3. Build (you run cargo; otigen post-processes)
cargo build --target wasm32-unknown-unknown --release
otigen build
# 4. Run the behaviour tests
otigen test
# 5. Deploy to devnet / testnet / mainnet
otigen deploy --network devnet
This loop is detailed in
OTIGEN_BINARY_SPEC §3.2 + §3.10.
The TOML format for tests/contract.test.toml is documented in
OTIGEN_TEST_SPEC.md.
Pre-mainnet status (today)
Pyde is pre-mainnet. What's already shippable:
- The protocol spec (everything in this book).
- The post-quantum cryptography crate: pyde-crypto.
- The engine workspace's interface layer (MC-0 —
phase-0-foundationtag onpyde-net/engine). - The marketing site you arrived from.
What's in active build-out:
- The engine (execution + consensus + node binary). MC-1 in flight across two parallel streams — see Implementation Plan §3.2.
- The otigen toolchain. MC-1 Stream α — see
pyde-net/otigen.
What you can do right now:
- Read the spec, file issues, propose PIPs.
- Watch the repos.
- Track the launch plan.
Where to ask
- GitHub Discussions — design questions, spec ambiguities.
- Telegram — quick chat, anything that doesn't need a paper trail.
- PIPs — propose a protocol change.
Welcome aboard.
Get Started — for Users
You're not here to write contracts. You want to use Pyde — hold PYDE, send a transaction, run a node, or follow the project's path to mainnet. This page is your map.
What's different about Pyde
Three things, in plain language:
1. It survives the quantum era
Every signature on Pyde uses FALCON-512, a NIST-standardised post-quantum signature scheme. Every encryption uses Kyber-768 (ML-KEM), NIST's post-quantum key-encapsulation scheme.
Translation: when a quantum computer powerful enough to break Bitcoin
- Ethereum signatures shows up, Pyde keeps working. There is no migration window because there's no ECDSA legacy to migrate away from.
Read more: Chapter 8 — Cryptography.
2. Front-running is structurally impossible
On most chains, the order of transactions inside a block is decided by whoever proposes the block — and that ordering is profitable. MEV bots pay validators to insert their trade in front of yours, drain your slippage, and move on.
Pyde encrypts transactions in the mempool with a key only the committee collectively holds. The committee commits to an order before any decryption share is released. By the time anyone can read what's inside a transaction, the ordering is already final. There is no profitable front-run because there's no information to front-run on.
Read more: Chapter 9 — MEV Protection.
3. Your account doesn't die when one key leaks
Native multisig is a protocol feature, not a contract every wallet re-implements. Lose a key, the rest of the keys still control the account. Coming post-mainnet: programmable accounts with spend limits, time locks, social recovery, and per-app session keys that can be revoked at any time.
Read more: Chapter 11 — Account Model.
Honest status (today)
Pyde is pre-mainnet. That means:
| What | When |
|---|---|
| Read the spec | ✅ Now (this book) |
| Open a wallet / acquire PYDE | ❌ Mainnet |
| Send a transaction | ❌ Mainnet (testnet earlier) |
| Run a validator | ❌ Mainnet |
| Run a full node | ❌ Mainnet (devnet earlier) |
| Follow the project | ✅ Now |
The sections below track the path from "pre-mainnet engineering" to "mainnet live".
What you can do right now
- Read the whitepaper. 30 minutes; covers everything at a digestible depth.
- Follow the launch plan. Phased to mainnet — no calendar dates; each phase ships when its bar is met.
- Join Telegram for project chat.
- Follow @pydenet on X for milestone announcements.
- Watch the GitHub org if you want to see the work as it lands.
When mainnet ships
You'll do the things you'd do on any L1, with two structural differences:
- Your address is 32 bytes (
0x+ 64 hex chars). Pyde doesn't truncate addresses the way Ethereum does. You'll see this in any Pyde-native wallet. - Your account survives single-key compromise if you set up native multisig at registration. The wallet UX will surface this as the default for non-trivial balances.
Gas works like Ethereum's EIP-1559 (no priority fees on Pyde — inclusion order isn't biddable), and the chain commits a wave every ~500 ms. Transactions land fast and final.
Where to follow along
- Launch Strategy — the phased path to mainnet.
- GitHub org — every repo, every commit.
- Telegram — community chat.
- X (@pydenet) — milestone announcements.
info@pyde.network— formal contact.
Welcome to the pre-mainnet phase. It's the most honest place to be.
Introduction
What is Pyde?
Pyde is a Layer 1 blockchain built greenfield to deliver four properties no chain in production combines today:
- Post-quantum cryptography by default — FALCON-512 signatures, Kyber-768 threshold encryption, Poseidon2 hashing
- MEV resistance by structure — threshold-encrypted mempool + commit-before-reveal ordering eliminates proposer extraction
- Sub-second finality — Mysticeti-style consensus, ~500ms median finality
- Commodity decentralization — modest hardware for validators not currently on the active committee; equal voting power within the active committee
The execution layer is WebAssembly via wasmtime, with Cranelift ahead-of-time compilation and a uniform Block-STM scheduler — every tx runs optimistically in parallel through an MVCC layer, conflicts are detected at runtime, losers re-execute until fixpoint. Wallet-attached access lists from pyde_simulateTransaction drive PIP-3 multiget prefetch into the dashmap cache before execution starts; the lists are performance hints, not scheduling decisions. Smart contracts can be authored in Rust, AssemblyScript, Go (TinyGo), or C — whatever language the team already uses — and bundled by the otigen developer toolchain.
Cross-chain interactions — calling functions on other chains, querying oracles, off-chain compute — happen through a permissionless parachain layer (post-mainnet) with operators who stake PYDE and earn gas fees from contracts that call them. No custodial multisigs, no auctioned slots.
The Pivots
Pyde has gone through two clean pivots that materially changed the architecture. Both are documented honestly in the preface (The Pivot) and supported by full historical design records in pivot/.
- Consensus pivot — from an in-house HotStuff variant (whose 400ms tail-latency wedges proved structural rather than tunable) to Mysticeti-style DAG consensus. The HotStuff-era consensus crates are archived; the Mysticeti-based rebuild is in progress.
- Execution pivot — from a custom virtual machine (
pyde-vm), a custom AOT compiler (pyde-aot), and a custom language (Otigen) to WebAssembly via wasmtime. The Otigen name lives on as the developer toolchain (otigen). The original Otigen Book is preserved as a historical artifact.
This book reflects the post-pivot architecture. The work that preceded each pivot is preserved both in code (archive/) and in design documentation (pivot/).
Why a New Layer 1?
The Quantum Problem
Every major Layer 1 in production today — Bitcoin, Ethereum, Solana, Cardano, Polkadot — uses classical cryptography (secp256k1, Ed25519, BLS12-381) vulnerable to Shor's algorithm. NIST's 2024 standardization of FALCON, ML-DSA, and ML-KEM unblocked post-quantum primitives, but retrofitting them into a live chain is a multi-year coordinated migration. Pyde ships PQ at genesis without retrofitting.
The MEV Problem
Maximum Extractable Value has hardened into a multi-billion-dollar tax paid by retail users to validator-builder coalitions. Sandwich attacks, front-running, and proposer extraction are not bugs — they are structural consequences of public mempools and single-proposer block production. Pyde eliminates the structural conditions via threshold encryption + commit-before-reveal ordering with no single proposer to exploit.
The Decentralization Problem
Chains optimizing for throughput have ended up requiring datacenter-class validator hardware. Chains optimizing for decentralization have ended up with throughput unusable for serious applications. Pyde scales hardware requirements by role — commodity for validators awaiting committee selection, modest professional for validators on the active committee at production targets, datacenter only for aspirational TPS levels.
What's New (Post-Pivot)
- Mysticeti DAG consensus replaces HotStuff. No view changes, no single proposer, sub-second commit latency targeted (implementation in progress)
- WebAssembly execution via wasmtime, with Cranelift AOT. Smart contracts written in Rust, AssemblyScript, Go, or C/C++ — same language ecosystem authors already work in
- Worker / Primary split (Narwhal pattern) for data dissemination separate from consensus
- Uniform Block-STM scheduler — optimistic parallel execution + MVCC validation; access lists from
pyde_simulateTransactiondrive PIP-3 prefetch into the dashmap cache before workers start - JMT state tree (Jellyfish Merkle Tree, radix-16) replaces fixed-depth SMT — with dual Blake3 + Poseidon2 roots so standard light clients and future ZK light clients verify against the same tree
- PIP-2 clustered slot keys + PIP-3 prefetch + PIP-4 write-back cache — three-layer state performance stack
- Encryption opt-in per-tx — MEV protection where needed, no overhead where not
otigendeveloper toolchain — zero-extra-code authoring: write contract logic +otigen.toml, the tool handles everything else- Honest performance targets — the v1 throughput target is validated by a multi-region performance harness before any number is published
- Phased mainnet plan — external audit + incentivized testnet before launch
Honest Status
This book describes designed architecture, with implementation in various stages:
| Component | Status |
|---|---|
| Architecture design | Complete |
| WASM execution layer (wasmtime + Cranelift) | Functional — substrate macros (#[pyde::entry] + typed storage + events) + cross-contract calls + typed-storage host fns all shipped |
| State layer (JMT, hybrid Blake3 + Poseidon2 hashing) | In place; hybrid hashing wired |
| Mysticeti-style consensus | Rebuild in progress post-pivot |
Post-quantum cryptography (pyde-crypto) | Functional; threshold-decryption path is research-grade |
| Network protocol (libp2p + QUIC + Gossipsub) | In place; layered peer discovery (no DHT) in flight |
Devnet (otigen devnet) | Shipped — chain runtime embedded in the otigen binary (no separate pyde download), one-command local devnet, 10 prefunded accounts |
otigen developer toolchain (WASM-era) | Shipped — scaffold / build / check / test / deploy / call / inspect / verify / wallet / console / validator across Rust / TinyGo / AssemblyScript / C; lifecycle commands (upgrade / pause / unpause / kill) scaffold a signed tx but refuse to submit (EngineNotReady) until the chain-side TxType::Lifecycle handler lands — v1 ships the proxy-pattern + author-declared paused/killed booleans |
| Parachain framework | Designed; implementation deferred to a later phase |
| Performance harness (multi-region, chain-throughput) | Not yet built (mandatory before any TPS claim) |
Mainnet ships when the implementation is complete, audited, and validated by an incentivized testnet — no public schedule. See Chapter 19: Launch Strategy for the phased path.
Performance Targets
Throughput is validated by a multi-region production-realistic harness (mandatory before any external claim). Pyde publishes no forward throughput number — the v1 honest throughput target is established only once the harness measures it under sustained, production-realistic conditions. Latency targets, by contrast, are concrete:
| Mode | v1 | v2 | Aspirational |
|---|---|---|---|
| Plaintext throughput (commodity) | awaiting harness | awaiting harness | awaiting harness |
| Encrypted throughput (commodity) | awaiting harness | awaiting harness | awaiting harness |
| Median finality | ~500ms | ~400ms | ~300ms |
The HotStuff Lesson: the pre-pivot implementation hit ~4K TPS in practice despite a higher claimed design target. Pyde now adopts the discipline of publishing only what the harness measures under sustained, production-realistic conditions — never lab extrapolations or microbenchmark peaks. No external TPS claim without harness evidence.
Reading Path
This book is the comprehensive technical reference. Different paths for different audiences:
For a researcher / cryptographer:
- Chapter 2: Architecture Overview
- Chapter 6: Consensus (Mysticeti DAG)
- Chapter 8: Cryptography
- Chapter 9: MEV Protection
- Companion: Whitepaper
For an implementer / contributor:
- Chapter 2: Architecture Overview
- Chapter 3: Execution Layer (WASM)
- Chapter 4: State Model
- Chapter 5: Otigen Toolchain
- Chapter 11: Account Model
- Chapter 12: Networking
- Companion: Architecture (Design Doc)
- Preface: The Pivot for context on architectural choices
For a validator operator:
- Chapter 6: Consensus
- Chapter 7: State Sync & Chain Halt
- Chapter 16: Security & Threat Model
- Companions: Validator Lifecycle, Slashing, Chain Halt & Recovery
For an investor / decision-maker:
- This Introduction
- Chapter 14: Tokenomics
- Chapter 19: Launch Strategy
- Companion: Tokenomics Detail
For someone doing security review / audit:
- Chapter 16: Security & Threat Model
- Chapter 6: Consensus (safety arguments)
- Chapter 8: Cryptography
- Companions: Threat Model, Failure Scenarios, Network Protocol, Performance Harness
License
Pyde is licensed under Apache-2.0. The full text lives in LICENSE at the root of each Pyde repository. The book content is licensed under CC BY-SA 4.0.
Status
Living document. Updated as the design evolves.
The Pivot
A note on how this book came to describe what it describes, and what changed along the way.
Starting from a question
Pyde began with a simple question that turned out to be much harder than it looked:
Can we build a post-quantum L1 that is actually fast?
Not "fast in the abstract." Not "fast in a research paper." Fast enough that real users would not notice it was post-quantum at all. Fast enough that the security upgrade was free at the point of use.
That question is what this book is about. Everything else — the consensus choice, the execution model, the state layer, the crypto primitives — is downstream of trying to answer it honestly.
This preface is the story of the answers we tried, the answers we kept, and the answers we threw away.
The first instinct: a small, sandboxed VM
The earliest sketches of Pyde leaned on something close to a BPF-style virtual machine. Solana had shown that a tight, sandboxed, register-based VM could run blockchain workloads at speeds that older designs (the EVM in particular) had no path to. The appeal was obvious: instead of inheriting a heavy stack-based VM with decades of cruft, start lean.
The thinking was: a small instruction set, a tight verifier, a fast interpreter or AOT, and crypto-friendly opcodes. Let the rest of the system inherit that lightness.
What we did not appreciate at the time — and what took building to learn — was that the VM is rarely the bottleneck on a blockchain. The bottleneck is consensus tail latency, signature verification, network bandwidth, and disk I/O, in roughly that order. The VM is the part you write last and that matters least. We would learn this the hard way, more than once.
But the BPF idea seeded something useful. It taught us to think in terms of sandboxing as a first-class property, not an afterthought. It taught us that "your own VM" is a commitment to building, maintaining, and securing an entire compiler toolchain — not a one-shot decision. Those lessons stuck. The specific implementation did not.
The HotStuff phase and a 400-millisecond wedge
For the full design record of this era, see hotstuff-consensus-era.
For consensus, we tried HotStuff first. It is the orthodoxy of modern BFT — used by Diem (the version that did not ship), Aptos, several other production chains. The literature is clean. The proofs are tight. The reference implementations are credible.
We picked it up and started integrating it.
For a while, things looked promising. Throughput was reasonable. The committee structure made sense. The pipeline of view changes felt mostly orderly. We started building around it: the mempool, the block production, the early state machine.
And then we ran into a wedge.
Under load, in adversarial conditions — partial network partitions, slow validators, particular orderings of messages — HotStuff's tail latency would balloon. We saw commits taking 400 milliseconds where the median was under 100. That tail was not a curiosity. It meant a real chain running under real conditions would routinely freeze for fractions of a second, and that was unacceptable for the kind of UX we wanted Pyde to enable.
We spent weeks trying to engineer around it. Tuning timeouts. Re-ordering message handlers. Experimenting with leader rotation strategies. Adjusting the view-change protocol. Some of these helped at the margin. None of them got the tail under control.
Eventually the honest read became: HotStuff is not the right base for what we are trying to build. The tail latency is not a tuning problem; it is a structural property of how leader-based BFT handles adversarial conditions. We could keep grinding on it for another year and not get there.
That was the first hard pivot.
We turned to the DAG family — Mysticeti, Narwhal, Blueshark. The DAG approach decouples data availability from ordering, removes the single-leader bottleneck per round, and gives the kind of tail latency profile we needed. Mysticeti specifically had the freshest design and the best throughput numbers in the literature.
We adopted it as the consensus design direction. The implementation is currently in progress — the HotStuff-era consensus crates are archived, and the new Mysticeti-based consensus layer is being built design-first against the post-pivot architecture. The architecture chapters that follow describe Mysticeti as the design Pyde is being built around, not as code that has already shipped.
The HotStuff work was not wasted. Building it taught us what a BFT pipeline really looks like under load. The instinct that "the latency tail is what kills UX" carried forward. But the code itself got archived. We learned, and we moved.
A smaller pivot worth recording: SMT to JMT
Around the same time we were working on the consensus layer, we were also evaluating the state-commitment structure. The clean theoretical answer was a Sparse Merkle Tree — a fixed-depth-256 tree, one of the most studied constructions for accountable state. Beautiful on paper.
Expensive in practice. Every state read or write touches roughly 256 nodes because of the fixed depth. At realistic TPS, that overhead dominates the disk IO budget. The math did not close.
We switched to the Jellyfish Merkle Tree (JMT) — radix-16, path-compressed, production-validated by Diem and Aptos. Same authentication properties (Merkle commitment, inclusion and exclusion proofs), but roughly 5-10 nodes touched per operation instead of 256. The IO budget closes. The chain ships at a realistic TPS instead of an aspirational one.
The SMT lessons did not disappear. They informed the current dual-hash JMT design, where the Poseidon2 path gives us the ZK-proof properties SMTs are known for, while the JMT structure underneath keeps the IO cost manageable. This was a smaller pivot than the consensus and execution ones, but it followed the same pattern: pick the cleanest theoretical answer first, run the numbers, switch to the production-grade variant when the cleanest answer does not survive contact with reality.
Building Otigen the language
For the full design record of this era, see otigen-language-era. The complete language reference, syntax, semantics, and standard library documentation are preserved in the otigen-book (now with a pivot-notice preface).
Around the same time, we made another decision that would also need revisiting later.
We decided to design and build our own smart-contract language.
Looking back, this was not an irrational decision. Given what we knew then, it was rational. The argument went like this: if Pyde is going to be opinionated about consensus, about cryptography, about state — about every layer of the stack — then the smart-contract language should be opinionated too. Otigen would be designed from day one around Pyde's semantics. Encryption-friendly. Threshold-decryption-aware. Nonce-window-native. Tight gas accounting. A clean compilation target for our pVM bytecode.
So we built it.
We built the compiler (otic). We built the bytecode interpreter (pyde-vm). We built the Cranelift-based ahead-of-time compiler (pyde-aot). We built the standard library. We built the developer toolchain (wright). We wrote a book about it (the otigen-book, still preserved as historical reference). We documented opcodes. We designed type semantics. We dogfooded contracts.
Real engineering. Real months of work.
For a while, the bet looked good. Otigen had personality. Its syntax was clean. The pVM was lean. The integration with Pyde's primitives — threshold encryption, access lists, nonce windows — was tighter than any general-purpose VM could match.
What we did not see clearly at the time was that building a smart-contract language is not a one-shot deliverable. It is a permanent commitment to a category of work that competes against everything else the chain needs. A language has to keep up with the host platform (toolchain updates, Cranelift API churn, security advisories). It has to add features real applications need that we did not predict in version one. It has to maintain backwards compatibility, or pay the cost of breaking it. It has to be fuzzed, audited, and hardened against an open adversary. It has to be documented for new developers, supported in IDEs, debuggers, profilers, linters, formatters. It has to be taught.
The deeper question, the one we eventually had to ask honestly, was whether all that ongoing work was paying for the right things. The language was not Pyde's differentiator. Solana's BPF is not why people use Solana. Polkadot's WASM is not why people use Polkadot. Aptos's Move-language is closer to a differentiator, but even there the chain competes on consensus and security, not on Move itself. Smart-contract languages are tools. They matter for developer experience. They do not move the needle on the question Pyde was created to answer — can we build a post-quantum L1 that is actually fast?
The work we were spending on the language was work we were not spending on the answer.
So we ran the honest math.
The honest reckoning
The decision was not made all at once. It accumulated.
There was the moment when a routine Cranelift API update broke the AOT compiler and took two days to chase down. There was the moment when a community developer asked whether they could write contracts in Rust, and we had to say "no, you have to learn Otigen first."
There was the moment when we read another paper on zk-WASM proving and realized that the WASM ecosystem was approaching native ZK execution proofs — work being pushed forward by several research groups in parallel — while a zk-Otigen prover would have to be built from scratch by us, and audited from scratch, and maintained from scratch.
There was the moment when we counted the audit surface honestly. A custom VM means:
- An internal audit of the bytecode interpreter, the AOT compiler, the sandbox boundary, the gas accounting, the trap handling.
- An external audit of all of the above, by a specialist firm willing to learn an instruction set that exists only here.
- Continuous fuzzing of the interpreter and the AOT against adversarial inputs.
- Re-audits whenever the language or the VM evolves.
- The same audit work, repeated, every year, indefinitely.
A WebAssembly runtime means: wasmtime, which is already vetted as production infrastructure by Bytecode Alliance, deployed at scale by Microsoft, Fastly, Shopify, and others. The sandbox has been fuzzed continuously for years. The instruction set is a public standard with academic and industrial scrutiny. We inherit that work at zero engineering cost. Our remaining audit surface shrinks to the host-function ABI and the chain-side integration — a small fraction of what a custom-VM audit would cost.
That was not a marginal saving. That was a reframe of how much engineering capacity Pyde would have to put into proving its own execution layer was safe, on a recurring basis, every year going forward.
And then there was the moment that settled it: we ran the numbers honestly.
The argument for keeping a custom VM had always rested on a quiet assumption — that our custom AOT, hand-tuned for our opcodes, would outperform a general-purpose WASM runtime. The reasoning sounded plausible: bespoke beats generic, surely. We had built pyde-aot carefully. It used Cranelift, the best open-source code generator outside of LLVM. It produced real native machine code. We had spent months on it.
So we looked at wasmtime. And we found out something that changed the whole equation.
Wasmtime also uses Cranelift. The exact same backend. The same code-generation passes. The same register allocator. The same machine-code emitter. The difference between pyde-aot and wasmtime's AOT was not the optimizer — it was the front-end that fed instructions into Cranelift.
And the WASM front-end is the path Cranelift was originally optimized for. WebAssembly is the workload Cranelift was built to serve well. Years of optimization passes, edge cases, calling-convention refinements — all targeted at WASM. Our Otigen front-end, by comparison, was new code touching the same backend. It had not been adversarially fuzzed. It had not benefited from a hundred outside contributors finding obscure miscompiles. It worked, but it was newer, less battle-tested, with smaller margins for the optimizer to extract performance from.
We then ran our own benchmarks instead of guessing. Real measurements on the existing PVM stack, on a developer workstation:
- PVM interpreter, ALU dispatch: ~279 million instructions per second.
- PVM AOT, ALU dispatch: ~2.9 billion instructions per second. A 10× speedup on tight compute loops.
- PVM AOT, DEX swap (constant-product AMM): ~100 million swaps per second, 3.7× faster than interpreted.
- PVM AOT, token transfer (storage-bound): ~243K transfers per second — essentially identical to the interpreter's ~231K. Storage IO dominates; the AOT compute advantage disappears.
- AOT compilation cost: under one millisecond for contracts under 256 instructions.
These numbers are single-thread micro-benchmarks of the execution layer in isolation — one VM, one workload, no consensus, no network, no parallel scheduling. They measure raw VM throughput, not end-to-end TPS. Full-chain TPS is governed by consensus latency, signature verification, network bandwidth, parallel scheduling, and disk IO in addition to VM execution; Pyde's realistic v1 throughput target (awaiting harness measurement) reflects all of those layers combined. The numbers above are useful for the VM-vs-VM comparison; they are not the chain's TPS.
Hardware used: Apple M4 Pro, 14 cores, 24 GB RAM, macOS 26.3.1.
Reproduce these numbers yourself: see pivot/03-running-benchmarks.md for the exact commands and the expected output shape.
Those are real numbers, not extrapolations. They tell us several things about where the VM actually matters.
The 10× speedup is on tight ALU loops. Real smart contracts are not tight ALU loops. They are storage reads, storage writes, signature checks, event emissions — workloads where the AOT-versus-interpreter gap collapses to roughly 1× because the bottleneck moves to RocksDB and to cryptographic verification, neither of which the VM can speed up. So the actual workload Pyde runs barely cares which VM compiles it.
When we mapped this against published wasmtime numbers — Cranelift-AOT WASM landing within 80-95% of native speed on compute, the interpreter at 10-30% of native — the comparison sat in the same range as our measurements. The two stacks are not in different leagues. They are in the same league, on the same backend, for the same reasons.
The interpreted comparison told the same story. A WASM interpreter (the fallback path when AOT cache is cold) achieves roughly the same throughput as our PVM interpreter — both sit in that 10-30 percent of native range, because both pay the dispatch cost. There was no meaningful interpreted-vs-interpreted advantage either.
So the speed argument for keeping Otigen quietly disappeared. The custom VM was not faster on the workloads that matter. It was just smaller-team-maintained, less-fuzzed, and lonelier.
What WASM offered was not just a comparable runtime. It was an already-vetted one. Production-deployed at Fastly and Microsoft and Shopify. Continuously fuzzed by an open community. Maintained by an entity that exists to maintain it. And we would pay essentially zero engineering capacity to inherit all of that — no compiler to support, no language to teach, no security maintenance to schedule. We got the speed plus the platform plus the ecosystem, in exchange for retiring a custom stack we had built for reasons that no longer held.
There was the moment when we looked at the surface area a credible v1 requires — consensus correctness, threshold cryptography, state sync, slashing, validator lifecycle, network protocol, parachain framework, audit prep — and realized that an in-house language committed us to maintaining a parallel track of work that competed with all of those for attention. Not because the language was harder than the consensus or the crypto. Because the language was optional in a way the others were not. Every chain ships consensus and crypto and state. Few chains ship their own language. The ones that do (Move, Vyper, Otigen) carry that as a perpetual obligation, and it is rarely the thing that determines whether the chain ships well.
We decided that Pyde would compete on what is actually unique to Pyde — post-quantum consensus, threshold-decrypted mempool, the cryptography stack — and inherit the rest from established WebAssembly tooling.
Pyde's execution layer pivoted to WebAssembly via wasmtime. Authors write contracts in Rust, AssemblyScript, Go, or C — whatever they already know. The compilation target is well-defined, the runtime is battle-tested in production at Fastly and Microsoft and Shopify, the sandboxing is verified by years of fuzzing, the gas-metering is built in, and the ZK-readiness path has actual researchers working on it.
This was not a defeat. It was the right call. The work we did building Otigen taught us what mattered (sandboxing, determinism, gas semantics, tight integration with Pyde primitives) and what did not (a custom syntax we had to teach the world). Everything that mattered carried forward into how we expose Pyde's primitives as WebAssembly host functions. The work was not wasted. The language was retired, but its lessons live in the new architecture.
The Otigen safety goodies are preserved
Worth being explicit about this because it is easy to assume a language-retirement loses the safety properties the language enforced. It does not.
Otigen's design defaults — reentrancy blocked by default, checked arithmetic, typed storage, no tx.origin, compile-time access list inference, the #[view] / #[payable] / #[reentrant] / #[sponsored] / #[constructor] attribute set — are all preserved unchanged in the WASM era. They are now expressed as language-native attributes (Rust #[pyde::view], AssemblyScript @pyde.view, Go //pyde:view, C PYDE_VIEW) that the build tool extracts into the ABI; the runtime applies the same guards it would have applied under Otigen.
Reentrancy is still blocked by default. The reentrancy guard is enforced at the WASM execution layer for every function not marked #[reentrant]. The author who writes nothing is still protected — exactly as in the Otigen era. See Chapter 5: Otigen Toolchain §5.6 for the full attribute surface and per-language declaration syntax.
The mechanism changed (build-time metadata + runtime enforcement instead of language compiler). The author experience and the safety guarantees did not.
What we got from the pivot
Worth naming explicitly, so the trade-offs are visible:
-
An already-vetted execution platform. Wasmtime is production infrastructure at Microsoft, Fastly, Shopify, and many others. The sandbox boundary, the determinism guarantees, the fuel-metered gas, the validation pipeline — all of it has been fuzzed continuously and hardened in adversarial conditions for years. We did not have to build any of it.
-
A dramatically smaller audit surface. A custom VM means auditing the interpreter, the AOT compiler, the sandbox, the gas accounting, the traps, and the language compiler — all from scratch, internally first and then externally, then re-audited as the system evolves. With wasmtime, our audit surface is the host-function ABI and the chain-side integration. Smaller scope, lower cost, faster turnaround, fewer specialists required.
-
Years of compounding maintenance work avoided. No language to keep current. No compiler to keep current. No AOT to keep current. No standard library to maintain. No IDE plugins, no debuggers, no formatters, no linters to write from scratch. The maintenance burden of a custom-language stack is permanent; pivoting away from it returns that capacity to Pyde's actual differentiators.
-
A clean ZK readiness path. zk-WASM is an active research area with multiple groups pushing it toward production. When mature, the provers slot in over our existing wasmtime execution — no re-tooling required on our end. zk-Otigen, by contrast, did not exist and would have been a multi-year side project for us alone.
-
Multi-language support out of the box. Authors write Pyde contracts in Rust, AssemblyScript, Go (via TinyGo), or C/C++. The barrier to entry is "the language you already use," not "the language Pyde wants you to learn." Developer adoption stops being gated by syntax familiarity.
-
A larger ecosystem of tooling. Block explorers, debuggers, profilers, fuzzers, formal verification tools — all exist for WebAssembly. We inherit them. Pyde-specific tooling can layer on top instead of starting from zero.
-
Time savings, measured honestly. The engineering capacity we would have spent maintaining Otigen — language design, compiler bug fixes, AOT bug fixes, standard library work, security advisories, ecosystem support — flows directly into the work Pyde actually competes on: post-quantum consensus, threshold cryptography, state-layer performance, validator lifecycle, parachain framework.
The trade-off we accepted: a small overhead on tight compute loops (which the benchmarks show is negligible for blockchain workloads, where storage IO dominates) and the loss of "Pyde has its own VM" as a marketing line (which was never a real differentiator anyway). For that price we got everything above.
The Otigen name lives on too. The new developer toolchain — the binary that scaffolds projects, generates state bindings, builds WASM artifacts, and deploys them — is called otigen. The same name, repurposed for the role it serves best: making the ergonomics layer feel as opinionated and integrated as the language was meant to be. The original otigen-book is preserved as a historical artifact, a snapshot of an earlier design phase that taught us what we needed to learn.
This is the same posture Rust's cargo takes (named for shipping containers, not for a programming concept) or Foundry's forge and cast take (craft-naming for tools). The name describes the role in the workflow, not the underlying technology.
Where we are now
The architecture that this book describes is the architecture after the pivots:
- Consensus: Mysticeti-style DAG, anchor-every-round, tail-latency-aware.
- Execution: WebAssembly via wasmtime, with Cranelift AOT for hot paths.
- State: Jellyfish Merkle Tree with dual hashing (Blake3 + Poseidon2), PIP-2 clustered slot keys for cache locality, dual roots so we can serve both standard light clients and future ZK light clients from the same tree.
- Cryptography: FALCON for signatures (post-quantum), threshold decryption as an opt-in mempool privacy path, Poseidon2 as our ZK-friendly hash, Blake3 for fast general hashing.
- Developer experience: the
otigenbinary owns the entire authoring lifecycle. Authors write only their contract logic and aotigen.toml. Everything else — language detection, build invocation, state binding generation, ABI emission, deploy-tx submission — is handled by the tool. - Parachains: WASM runtime per parachain, equal-power governance, full upgrade history retention, ENS-style name registration.
Each of these is the result of trying something else first, hitting a wall, and learning what the wall was made of. The book chapters that follow describe each layer in detail. This preface is here so that when you read about Mysticeti instead of HotStuff, or WASM instead of Otigen-the-language, you know that those choices were the outcome of work, not first instincts.
The first instincts were wrong, mostly. The current architecture is what was left after the wrong ones were honestly retired.
What this pivot does not change
It is worth being explicit about what stays the same, because the changes have been substantial and a casual reader could conclude that everything is in flux. It is not.
The core thesis is unchanged: post-quantum from day one, practical performance, decentralized validator set, light-client-verifiable state, opt-in transaction privacy via threshold decryption.
The consensus model is unchanged from the Mysticeti pivot onward: DAG-based, anchor-per-round, equal-power VRF-rotated committee.
The state layer is unchanged from the JMT decision onward: versioned Merkle tree, hash-friendly to both general hashing and ZK provers, PIP-2 clustering for locality.
The cryptography is unchanged: FALCON, Poseidon2, Blake3, threshold decryption via DKG.
The PIPs (Pyde Improvement Proposals) — PIP-2 clustered state keys, PIP-3 scheduler-level prefetch, PIP-4 application-level write-back cache, the dual-hash JMT — all carry forward unchanged. They are layer-agnostic. The execution VM does not affect them.
The pivot is localized. Most of Pyde's design carries through.
What this book is, and is not
This book is the current architecture of Pyde, as honestly as we can describe it. It is updated as design decisions land. The chapters that follow assume the pivots described here have happened; they do not repeatedly say "after the WASM pivot" or "before the consensus change." Read those as historical facts that informed what is described here.
This book is not a marketing document. It does not promise speeds we have not measured. It does not list partnerships that do not exist. It does not paper over the parts of the design that are still hard. Where something is uncertain, we say so. Where we have changed our minds, we say that too.
If you came here looking for a clean, never-pivoted, always-knew-the-answer story — that is not what Pyde is, and not what this book is. Pyde is what happens when someone decides to build a post-quantum L1, runs into every wall the architecture has to offer, and writes down what remained after the dust settled.
For the deep technical material on the earlier iterations — the HotStuff consensus design that preceded Mysticeti, and the Otigen language design that preceded WebAssembly — see the Pivot folder, which includes the design records and a step-by-step guide to running the pivot-era benchmarks on your own machine. The narrative is here; the design records are there.
The book starts now.
Pivot — Historical Design References
This directory preserves Pyde's earlier architectural iterations as first-class historical material. Pyde has gone through two clean pivots that materially changed the protocol design, and the work that preceded each pivot is documented here so it can be studied, learned from, and properly credited.
Read the preface first if you have not already — it is the narrative companion to this directory. The preface tells the story; this directory holds the design records.
Contents
| Document | Era | Status |
|---|---|---|
| 01 — The HotStuff Consensus Era | Pre-Mysticeti consensus design | Retired |
| 02 — The Otigen Language Era | Pre-WASM execution design (custom language + VM + AOT) | Retired |
Each document summarizes what was designed, what was built, what was learned, and where the deep technical material lives (which archived repos, which book, which design docs). The summaries are not re-derivations of the original work — they are pointers + context for reading the originals correctly.
Why this exists
Three reasons:
-
The work is real. Building these systems taught us what mattered and what did not. The current architecture is informed throughout by lessons from these earlier iterations. Pretending the work never happened would be both dishonest and counterproductive — future architects (Pyde or otherwise) can learn from the trade-offs we explored.
-
Honesty is the project's posture. Pyde's design has changed in response to evidence. Documenting the changes openly is the same discipline that made the changes possible. A reader who lands here looking for "why did Pyde stop using X?" deserves a real answer with real material, not a 404.
-
Some of these designs are independently interesting. The Otigen language, the custom register-based VM, the pre-Mysticeti HotStuff integration, the early access-list scheduler — these are not generic patterns. They were thought through carefully. Someone designing a similar system elsewhere may find the trade-offs documented here useful.
How to read what's here
Each document follows the same shape:
- What we built — the design, in summary form.
- Why we built it that way — the constraints and reasoning at the time.
- What we learned — what survived the pivot, intellectually.
- Where the original material lives — links to archived code, archived docs, and the otigen-book for language-specific content.
Read in the order presented (01 then 02). The two pivots happened in sequence; the second was informed by lessons from the first.
Reading order for the whole pivot story
- Preface: The Pivot — the narrative.
- This directory's 01 — HotStuff Era and 02 — Otigen Era — the design records.
- The main book chapters — the current architecture.
01 — The HotStuff Consensus Era
The first consensus protocol Pyde adopted was an in-house variant of HotStuff. This document summarizes that design, why we chose it, what it taught us, and where the original material lives.
What we built
A linear, pipelined HotStuff variant tuned for Pyde's committee model:
- Three-phase commit pipeline — prepare, pre-commit, commit, decide, with each phase carrying a quorum certificate (QC) from the prior phase.
- Leader-driven block production — one leader per view, leaders rotate per view via deterministic rotation.
- 128-validator committee — the same committee size we still use today (preserved across the pivot).
- 400ms slot timing — target round duration of 400ms, with adaptive timeouts on view changes.
- FALCON-512 quorum certificates — 85-of-128 signatures aggregated into a QC, with the FALCON signature scheme preserved across the pivot.
- Pipelined view changes — to avoid the canonical HotStuff round-trip stall, view changes were pipelined into the steady-state flow.
The architecture lived in a consensus crate inside the engine workspace, alongside the (then-) PVM execution layer and the state crate.
Why we built it that way
HotStuff was the orthodoxy of modern BFT at the time. Used by Diem (Meta's version that did not ship), adopted by Aptos, validated in academic literature, with reference implementations available. The properties looked right for Pyde:
- Linear message complexity (vs PBFT's quadratic).
- Optimistic responsiveness (commits at the speed of the network, not at fixed timeouts).
- Simple safety + liveness proofs.
- Established ecosystem of HotStuff variants to learn from (LibraBFT, AptosBFT, HotStuff-2).
The constraint set Pyde faced — equal-power validators, sub-second commits, geographic-distribution-tolerant — looked like a clean HotStuff fit on paper.
So we built it.
What went wrong
Under load, in adversarial conditions — partial network partitions, slow validators, particular orderings of messages — HotStuff's commit latency tail ballooned. Median commits stayed under 100ms; tail commits ran out to 400ms and beyond. The chain "wedged" intermittently: not formally halted, just unable to deliver low-latency commits when conditions degraded.
We engineered against the tail for weeks. Tuning timeouts, re-ordering message handlers, experimenting with different leader-rotation schedules, adjusting the view-change protocol. Some of these helped at the margin. None of them got the tail under control structurally.
The honest read became: HotStuff's latency tail is not a tuning problem. It is a structural property of leader-based BFT under adversarial conditions. Different parameters give different tail shapes; none of them give a flat tail. We could keep grinding for another year and still ship a chain that wedged.
What we learned
Three lessons survived the pivot intact:
-
Tail latency is the UX killer, not median latency. A chain that commits in 100ms on average but stalls for 400ms in the tail will feel broken to users. The current Mysticeti-based design is specifically chosen for its better tail-latency profile under adversarial conditions, not for its median performance.
-
DAG consensus is structurally different from leader-based BFT, in ways that matter. The single-leader bottleneck in HotStuff is what produces the tail; removing the bottleneck (per-round, every validator can produce a vertex) removes the structural source of the tail.
-
Build to learn, but be willing to throw it away. The HotStuff integration was real engineering work. We did not regret building it — we regretted not pivoting away from it sooner. The retrospective lesson: when the data says "this won't get there," act on it. Do not engineer-around the structural problem.
What survived
Several pieces of the HotStuff-era architecture carried forward into the current Mysticeti-based design without change:
- The 128-validator committee size with 85-quorum threshold.
- The FALCON-512 signature scheme for quorum certificates.
- The equal-power, VRF-rotated committee selection model.
- The general wave abstraction (a periodic commit unit with an associated state root).
- Much of the supporting infrastructure: state layer, mempool admission, transaction types, validator lifecycle.
The pivot was localized to the consensus core. Everything that touched consensus from above or below stayed.
Where the original material lives
- Source code —
archive/crates/consensus/(in the umbrella repo). The HotStuff implementation, including the QC types, view-change protocol, and leader-rotation logic. - Design notes —
archive/crates/consensus/CONSENSUS_INVARIANTS.mddocuments the consensus invariants the HotStuff implementation upheld. - Original whitepaper —
archive/WHITEPAPER.mddescribes the early-architecture vision including HotStuff as the consensus choice. - Pre-pivot engine crates —
archive/crates/more broadly contains the consensus-adjacent crates from this era (mempool integration, transaction processing under HotStuff semantics).
The archive directory is preserved with git history intact. Anyone wanting to study the HotStuff-era implementation can browse it directly or check out the git revision before the consensus pivot.
Reading on
- 02 — The Otigen Language Era — the second pivot, on the execution layer.
- Chapter 6: Consensus (Mysticeti DAG) — the current consensus design.
- Preface: The Pivot — the narrative version of both pivots.
02 — The Otigen Language Era
The second large pivot Pyde went through was the retirement of the custom execution stack — language, VM, AOT, toolchain — in favor of WebAssembly via wasmtime. This document summarizes what we built in the Otigen-language era, why we built it that way, what we learned, and where the original material lives.
What we built
A complete custom execution stack for Pyde, four interlocking components:
Otigen — the language
.oti source files, surface syntax inspired by Rust, semantics tuned for blockchain execution:
- Reentrancy blocked by default; opt-in via the
#[reentrant]attribute. - Checked arithmetic by default; wrapping operations explicit.
- Typed storage via the
storage { ... }block. - No
tx.origin— the language did not expose it. #[view]/#[payable]/#[constructor]function attributes.- Compile-time access-list inference for the parallel scheduler.
- 4-byte function selectors derived from signature hashes.
otic — the compiler
.oti source → PVM bytecode + JSON ABI. Architecturally a four-stage pipeline: lex → parse → resolve → typecheck → safety analysis → bytecode emit. Implemented in Rust as a standalone library + binary.
pyde-vm — the virtual machine
A custom register-based VM:
- 16 × 64-bit general-purpose registers.
- 8 × 256-bit wide registers (for token amounts, hashes, signature components).
- 32-bit fixed-width instruction encoding.
- 62 opcodes covering ALU, memory, storage, crypto, host calls, and control flow.
- Static 4MB memory map with gas-metered page allocation.
- Trap-on-overflow by default.
pyde-aot — the ahead-of-time compiler
PVM bytecode → native x86 / aarch64 machine code, via the Cranelift code generator. Compiled at contract deploy time; the resulting native function was cached forever (contracts were immutable).
wright — the developer toolchain
Project-level CLI (init, build, test, deploy, wallet, console) analogous to Foundry for Solidity. Wrapped the otic compiler with project conventions and a deployment client.
Why we built it that way
The original argument: Pyde was going to be opinionated about every layer (consensus, cryptography, state, MEV protection), so the language layer should be opinionated too. Otigen would be designed from day one around Pyde's semantics — encryption-aware, threshold-decryption-friendly, nonce-window-native, with tight gas accounting and a clean compilation target.
The constraints we wanted the language to address:
- Reentrancy footguns in Solidity contributed to billions of dollars of lost funds historically. Block by default.
- Arithmetic overflow caused the bZx incidents, the YAM rebase bug, others. Check by default.
- Untyped storage in EVM led to slot-collision bugs. Type it.
- tx.origin was a phishing vector. Do not expose it.
- Dynamic dispatch unpredictability broke parallel execution in EVM. Infer access lists at compile time.
These were real problems we wanted addressed structurally, not via developer discipline.
We also believed at the time that a custom VM with a custom instruction set, tightly designed for blockchain operations, would outperform a general-purpose runtime. The PVM's wide-register file was specifically designed for 256-bit token amounts and hash operations.
So we built the whole stack.
What went right
Several pieces of the design worked exactly as intended:
- Otigen's safety defaults caught a class of contract bugs at compile time that would have been runtime failures in EVM.
- Compile-time access-list inference enabled the parallel scheduler to run non-conflicting transactions concurrently, a real performance win.
- The wide-register file was clean for 256-bit operations.
- The AOT compiler produced native code via Cranelift; benchmarks showed 10× speedup on tight ALU loops vs the interpreter.
- The wright toolchain offered a Foundry-quality developer experience.
The engineering work was real. The design was coherent. The team built it carefully.
What went wrong
Two things, accumulating over time:
One — the maintenance commitment was not one-shot
Building a language is a permanent commitment, not a one-time deliverable. Toolchain churn (Cranelift API updates breaking the AOT), feature requests from authors, security advisories, fuzzing, audit prep, documentation, IDE support — all of it had to be sustained continuously. The language was a parallel track of work that competed with the rest of the protocol for attention.
Two — the speed argument did not hold
The case for keeping a custom VM rested on the assumption that custom-AOT would outperform a general-purpose WASM runtime. Investigation showed otherwise:
- Both pyde-aot and wasmtime use the same Cranelift backend.
- WebAssembly is the workload Cranelift was originally optimized for.
- Our Otigen front-end was newer, less battle-tested, less fuzzed.
- Direct benchmarks showed wasmtime-AOT throughput in the same range as pyde-aot.
- On storage-bound workloads (the workload shape that matters for blockchain TPS), the AOT-vs-interpreter advantage collapsed to roughly 1× regardless of which AOT was running.
Measured numbers from the existing PVM stack on commodity hardware:
| Workload | PVM Interpreter | PVM AOT | AOT speedup |
|---|---|---|---|
| ALU dispatch | ~279M instr/sec | ~2.9B instr/sec | 10.4× |
| DEX swap | ~27M swaps/sec | ~100M swaps/sec | 3.7× |
| Token transfer | ~231K tps | ~243K tps | 1.05× (storage-bound) |
Token transfer — the canonical real-world workload — showed no meaningful AOT advantage because RocksDB IO dominates. WASM-AOT sits in the same range as PVM-AOT on the same backend. The custom VM was not faster on the workloads that matter.
What we learned
The lessons that survived the pivot intact, expressed now in the WASM-era architecture:
-
The VM is not the bottleneck. Real blockchain throughput is signature verification + IO + consensus + network bandwidth, in roughly that order. The VM is the fifth contributor. A 10% VM-level slowdown is invisible to TPS.
-
Sandboxing, determinism, gas semantics matter. All three. The WASM execution layer enforces them via wasmtime's feature-flag config, fuel-based metering, and deploy-time validation. The Otigen-era discipline about these properties carried forward.
-
Author safety is a property of host functions, not language syntax. Reentrancy guards, checked arithmetic, type-safe storage access — all of these can be expressed as patterns in the WASM host-function ABI and the binding generators, without requiring authors to learn a new language. The current
otigentoolchain (the binary; same name, new role) emits language-specific bindings that preserve these guarantees in Rust, AssemblyScript, Go, and C. -
Compile-time access lists work, regardless of source language. The current architecture preserves access-list inference (from the binding generators deriving from
otigen.toml) as a prefetch optimization within the uniform Block-STM scheduler; the lists are now produced by the binding generators from theotigen.tomlstate schema rather than by the Otigen compiler. Same property, different surface. -
A custom language costs more than its benefit returns. The language was not Pyde's differentiator. The work spent on it was work not spent on the post-quantum consensus + crypto + state stack that actually is the differentiator. The pivot redirected that work.
What survived
A lot, in fact:
- The safety properties Otigen aimed for — reentrancy guards, checked arithmetic, typed storage, no
tx.origin— are preserved in the host-function ABI and the binding generators. - The compile-time access-list inference is preserved (now produced by the binding generators from
otigen.toml). It now functions as a prefetch optimization in the uniform Block-STM scheduler rather than a scheduling mechanism; the lists are never used to partition execution. - The state model (JMT, PIP-2 clustering, dual-hash) was already architecturally separate from the VM; no changes needed.
- The wave model, gas accounting, threshold encryption, and all the consensus-side properties were preserved without change.
- The otigen name itself — repurposed for the developer toolchain, where it now describes the role of "making the ergonomics layer feel coherent and opinionated."
The pivot was localized to the VM and the language. Everything around it stayed.
Where the original material lives
- The otigen-book — the canonical reference for the Otigen language. Preserved as a published historical artifact at
pyde-net/otigen-bookwith a pivot-notice preface explaining the current status. - otic compiler source —
pyde-net/oticrepo, archived (read-only). - wright toolchain source —
pyde-net/wrightrepo, archived (read-only). - pyde-vm and pyde-aot crate source —
archive/crates/pvm/andarchive/crates/aot/in the umbrella repo, preserved with git history. - Original Otigen-era documentation —
archive/more broadly contains the pre-pivot READMEs, design notes, and benchmark plans. - Benchmark numbers — see the bench files in
archive/crates/pvm/benches/andarchive/crates/aot/benches/. The numbers used in this document and in the preface were captured by running those benchmarks one final time before archival.
Reading on
- 01 — The HotStuff Consensus Era — the first pivot.
- Chapter 3: Execution Layer (WASM) — the current execution model.
- Chapter 5: Otigen Toolchain — the new role for the Otigen name.
- Preface: The Pivot — the narrative version of both pivots.
03 — Running the Pivot-Era Benchmarks
This document is the reproducer for the benchmark numbers cited in the pivot preface and in 02 — The Otigen Language Era.
The benchmarks measure the pre-pivot PVM execution layer (the now-retired pyde-vm interpreter and pyde-aot Cranelift-AOT compiler) in isolation. The benchmark code lives in the archive repository at archive/crates/pvm/benches/ and archive/crates/aot/benches/ (preserved after engine cleanup). You can run it today on any machine that has Rust installed.
The point of running these is not to validate Pyde TPS. The point is to see for yourself the relationship between interpreter throughput, AOT throughput, and storage-bound real-world workloads — the relationship that drove the WASM-pivot decision. The numbers favor WASM because they show that on storage-bound workloads (the ones that determine real chain TPS), the AOT advantage collapses, which means the VM choice does not move the needle.
Reference machine for the numbers in the book
| CPU | Apple M4 Pro |
| Cores | 14 physical / 14 logical |
| RAM | 24 GB |
| OS | macOS 26.3.1 |
| Rust toolchain | stable (any recent stable release works) |
If your machine is faster, you will see higher numbers. If slower, lower. The ratios (AOT-vs-interpreter speedup, storage-bound vs compute-bound) should hold across hardware.
Prerequisites
You need:
- A clone of the
pyde-net/archiverepository (where the retired pre-pivot crates live). - A stable Rust toolchain.
rustup install stableif you do not have it.
That is all. No extra build tools, no test fixtures to download.
Step by step
# 1. Get to the archive workspace.
cd <your-pyde-checkout>/archive
# 2. Run the PVM interpreter benchmark.
cargo bench -p pyde-vm --bench interpreter_bench
Expected output shape:
=== 0236: Interpreter throughput ===
--- ALU dispatch (no memory, no storage) ---
Loop iterations: 100000
Instructions/run: 800005
Runs: 100
Total time: ~270-300ms (depends on CPU)
Throughput: ~280-300 million instructions/sec
Latency: ~3-4 ns/instruction
--- ALU dispatch (with trace recording) ---
Throughput: ~310-340 million instructions/sec
(slightly faster than no-trace by design — see bench comments)
=== 0237: Token transfer execution time ===
--- Token transfer: setup cost ---
Latency: ~2-3 µs/setup
--- Token transfer: execution only ---
Throughput: ~220-240 thousand transfers/sec (execution only)
Latency: ~4-5 µs/transfer
--- Token transfer: full lifecycle ---
Throughput: ~140-160 thousand transfers/sec
Latency: ~6-7 µs/transfer
# 3. Run the AOT-vs-interpreter benchmark.
cargo bench -p pyde-aot --bench aot_bench
Expected output shape:
=== AOT vs Interpreter throughput ===
Interpreter: ~280 million instr/sec
AOT: ~2.9 billion instr/sec
Speedup: ~10x (compute-bound)
=== AOT Token Transfer ===
Interpreter (exec only): ~230 thousand transfers/sec
AOT (exec only): ~240 thousand transfers/sec
Speedup: ~1.0x (storage-bound — this is the point)
=== AOT DEX Swap (constant-product AMM) ===
Interpreter: ~27 million swaps/sec
AOT: ~100 million swaps/sec
Speedup: ~3.7x (mixed compute + state)
=== AOT compilation time ===
4 instructions: ~50 µs
16 instructions: ~100 µs
64 instructions: ~250 µs
256 instructions: ~950 µs
That is everything. Two cargo-bench invocations, two text reports.
What the numbers mean — and what they don't
These are single-thread micro-benchmarks of the execution layer in isolation. There is no consensus running, no network, no parallel scheduling, no real RocksDB IO under sustained write pressure. They measure how fast one VM runs one workload on one thread.
What you should take from them:
- AOT crushes interpreter on tight compute (10× on ALU loops, 3.7× on AMM math). Cranelift is doing real work.
- AOT advantage collapses on storage-bound workloads (token transfer: 1×). This is the workload shape that dominates real blockchain throughput. The VM is not the bottleneck for real applications; storage IO is.
- The interpreter is already fast, around 280 million instructions per second on this hardware. Cold-cache execution paths in production do not have catastrophic latency.
What you should not take from them:
- These are not Pyde's TPS numbers. Full-chain TPS depends on consensus latency, signature verification throughput, network bandwidth, the parallel scheduler, and disk IO in addition to VM execution. Pyde's realistic v1 throughput target — awaiting the multi-region performance harness — reflects all of those layers combined, not just the VM.
- These do not include parallel execution. Each benchmark above runs one workload on one thread. The production scheduler runs many workloads in parallel via uniform Block-STM; wallet-attached access lists serve as prefetch hints to warm the cache before workers start. That compounds throughput but is measured separately by the full-chain harness, not here.
- These do not separate memory reads from memory writes, or from disk IO. The token-transfer benchmark exercises storage IO end-to-end as a single number; it does not isolate "Sload cost" from "Sstore cost" from "leaf-hash recomputation cost." That level of decomposition is the job of the per-component micro-benchmark suite (in flight; see below) and the full-chain performance harness.
More detailed benchmarks (in flight)
The benchmarks above are deliberately simple — they were enough to drive the pivot decision. A more sophisticated suite is part of the planned performance harness work, covering:
- Per-host-function micro-benchmarks — measuring the cost of each WASM host function (sload, sstore, transfer, threshold_*, hashing primitives, etc.) in isolation, so the gas-cost table can be calibrated against real hardware.
- Sequential vs parallel execution — measuring how the Block-STM parallel scheduler, optimized with access-list prefetch, scales with core count on workloads with various access-conflict ratios.
- Memory read vs memory write vs disk IO — splitting state-layer cost by category, so the JMT + RocksDB + write-back cache (PIP-4) stack can be profiled independently.
- Workload mixes — realistic blends of transfer / token-op / DEX / NFT-mint / encrypted txs, with the realistic-mix fraction tracked over time.
- Multi-region full-chain TPS — the end-to-end measurement with consensus, network, and IO all under load.
Those benchmarks live with the performance harness, not in the engine bench files. See the Performance Harness document for the full testing methodology, what's planned, and the publishing discipline that governs how numbers are released — publish only what the harness measures under sustained, production-realistic conditions, never lab extrapolations or microbenchmark peaks.
What you can do with this guide
- Reproduce the pivot-decision numbers on your own hardware — see the ratios for yourself.
- Sanity-check the WASM-pivot reasoning — confirm that storage-bound workloads neutralize the AOT advantage, the empirical observation that drives the "VM choice does not move TPS" claim.
- Establish a baseline for comparing future WASM-execution numbers — once the WASM execution layer ships, equivalent benchmarks can be run against it; the numbers should sit in the same ballpark (within ~10%) per the pivot's expected outcome.
Where the benchmark code lives
| Benchmark | Source |
|---|---|
interpreter_bench | archive/crates/pvm/benches/interpreter_bench.rs |
aot_bench | archive/crates/aot/benches/aot_bench.rs |
| (future) WASM-equivalent benches | wasm-exec/benches/ in the fresh post-pivot engine repo (to be added) |
| (future) host-function micro-benches | same crate |
| (future) full-chain harness | separate repo (planned) |
The benchmark files live in the archive repository under archive/crates/pvm/benches/ and archive/crates/aot/benches/ — preserved with git history intact, runnable indefinitely. When the WASM execution layer ships in the freshly-cut post-pivot engine repo, equivalent benchmarks will be added under wasm-exec/benches/ so the same workload shapes can be measured on the WASM stack for comparison.
Reading on
- Preface: The Pivot — narrative context for these numbers.
- 02 — The Otigen Language Era — the full design record for the system being benchmarked.
- Performance Harness — the multi-layer testing methodology that succeeds these micro-benchmarks.
- Chapter 3: Execution Layer — the WASM execution architecture that replaces what's being measured here.
Architecture Overview
System Architecture
Pyde is a monolithic Layer 1 — consensus, execution, and state in a single binary. Validators and full nodes run the same pyde process; role differentiation is configuration (whether the node stakes, whether it joins the active committee, whether it serves RPC).
┌─────────────────────────────────────────────┐
│ Application Layer │
│ WASM smart contracts, dApps, wallets, RPC │
├─────────────────────────────────────────────┤
│ Execution Layer │
│ WebAssembly (wasmtime + Cranelift AOT), │
│ Block-STM scheduler, MVCC, access-list │
│ prefetch │
├─────────────────────────────────────────────┤
│ State Layer │
│ Jellyfish Merkle Tree (JMT), dual-hash │
│ Blake3 + Poseidon2 per node, PIP-2 clusters │
├─────────────────────────────────────────────┤
│ Consensus Layer │
│ Mysticeti DAG, anchor selection, finality │
├─────────────────────────────────────────────┤
│ Cryptography Layer │
│ FALCON-512, Kyber-768 threshold, DKG │
├─────────────────────────────────────────────┤
│ Network Layer │
│ libp2p + QUIC, Gossipsub, worker/primary │
└─────────────────────────────────────────────┘
Worker / Primary Split (Narwhal Pattern)
Within each validator, the consensus role is split:
- Workers (N processes per validator): handle transaction ingress, build batches of incoming transactions, gossip batches peer-to-peer with other validators' workers
- Primary (one process per validator): handles consensus — produces vertices each round, gathers parent references, signs state roots
This separation decouples high-volume data dissemination from low-volume consensus structure. Transactions travel the network exactly once (via worker gossip); consensus vertices stay tiny (carry only batch hashes by reference).
┌────────────────────────────────────────────────────┐
│ Validator Process │
│ │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Workers │ │ Primary │ │
│ │ (1 or more) │◄───┤ - Produces vertices │ │
│ │ │ │ - Tracks DAG │ │
│ │ - Tx ingress │ │ - Signs state roots │ │
│ │ - Build │ │ - Runs DKG ceremonies │ │
│ │ batches │ │ - Executes WASM │ │
│ │ - Gossip │ └──────────────────────────┘ │
│ │ batches │ │
│ └──────────────┘ │
└────────────────────────────────────────────────────┘
Workers can be scaled independently of the primary. A validator with high incoming traffic can run 4-8 workers; a quieter validator can run 1.
Consensus: Mysticeti DAG
Pyde's consensus is a Mysticeti-style DAG protocol. Every round (~150ms), each committee member's primary produces exactly one vertex. The vertex contains:
- Batch hashes (data layer references)
- 85+ parent vertex hashes (consensus structure, from prior round)
- State root signatures (attestations on recent commits)
- Anchor attestation (prior round's anchor vertex hash)
- Decryption shares (piggybacked partial decryptions)
- FALCON signature
Vertices form a Directed Acyclic Graph: parents must be strictly from prior rounds. This is purely a consensus structure; transaction data lives in batches referenced by hash.
Each round has a deterministically-selected anchor:
anchor_member = Hash(beacon, round, prev_state_root) mod 128
When the anchor vertex collects sufficient support from later rounds (Mysticeti 3-stage support), a commit fires. ~95% of rounds commit successfully; ~5% skip (next round absorbs the skip).
End-to-end commit latency: ~500ms median.
Execution: WebAssembly + Block-STM
After consensus commits a wave (canonical ordered transactions), the execution layer:
- Threshold decryption for encrypted transactions (≥85 partials combined per tx)
- Access-list prefetch — one batched
state_cf.multi_get(PIP-3) over the union of every tx's declared(addr, slot)pairs lands warm values in the dashmap (PIP-4) before workers start. The lists are hints only; they never partition the wave or affect correctness. - Block-STM scheduler runs every tx in parallel on a
rayonpool: optimistic execute against an MVCC layer + validate against canonical tx_index order + cascade-invalidate + re-incarnate on conflict + fixpoint. Final state per slot is the highest-tx_index's last write. Full algorithm in companion/BLOCK_STM_EXECUTION.md. - wasmtime executes each tx with Cranelift AOT and fuel-based gas metering. Smart contracts compile from Rust, AssemblyScript, Go, or C/C++ to WASM.
- State root computed — dual-hash (Blake3 + Poseidon2) per JMT node
- Committee FALCON-signs state root (piggybacked on next vertices)
- Finality when ≥85 state root signatures collected
State: Jellyfish Merkle Tree
Account state and contract storage are stored in a Jellyfish Merkle Tree (JMT) — radix-16, path-compressed. Compared to a fixed-depth-256 Sparse Merkle Tree:
- ~5-10 nodes touched per state operation (vs ~256)
- Substantial I/O savings at high TPS
- Same authentication properties (Merkle commitment, inclusion / exclusion proofs)
- Production-proven (Diem, Aptos)
State commitment is dual-rooted:
- Blake3 root: fast native verification (committee + validators)
- Poseidon2 root: ZK-circuit-friendly (future light clients, validity proofs)
Cryptography Layer
Three primitives form the cryptographic foundation:
FALCON-512 (Signatures)
NIST FIPS 206 standard. Used for: user tx authorization, vertex production, state root attestations, decryption share authentication. 666-byte signature, ~80μs verification.
Kyber-768 Threshold (Encryption)
NIST FIPS 203 standard with threshold variant. Per-epoch public key from DKG; ≥85 partials decrypt any ciphertext. Enables encrypted-mempool MEV resistance.
Poseidon2 + Blake3 (Hashing)
Hybrid layered: Blake3 for high-volume native paths (JMT internals), Poseidon2 for ZK-bearing paths (state root commitment exposed to future ZK proofs, address derivation, FALCON sig hashing inside ZK circuits).
Network Layer
- Transport: QUIC over UDP (no HOL blocking, TLS 1.3 built-in, mature in Rust via quinn). TCP fallback.
- P2P library: libp2p (Rust) — mature, audited, used by Ethereum/Filecoin/Polkadot
- Peer discovery: layered (hardcoded → DNS → on-chain validator registry → PEX → cache). No DHT.
- Gossip: Gossipsub with per-topic meshes
- DoS protection: 4-layer (connection / message / peer-scoring / application)
- Committee defense: sentry node pattern (Cosmos-style)
Committee NIC requirement at v1's honest throughput target (to be established by the multi-region performance harness) is ≥500 Mbps. Higher-throughput regimes are post-mainnet scaling work; the v1 target is what mainnet hardware is sized against.
Account Model
Accounts hold:
- nonce (8 bytes)
- balance (16 bytes, u128)
- gas_tank (16 bytes — pre-deposited gas for encrypted submission)
- auth_keys (variable: Single | Multisig | Programmable)
- code_hash (32 bytes, for contracts)
- storage_root (32 bytes, JMT subtree for contract storage)
- key_nonce (4 bytes, FALCON key rotation counter)
Native multisig at v1 — AuthKeys::Multisig(M, [pubkey_1, ..., pubkey_N]) with max 16 signers. Better than Gnosis Safe contract-multisig (Ethereum), which reimplements the same logic with subtle bugs across projects.
Programmable accounts and session keys ship post-mainnet. v1 reserves the Programmable enum variant so contracts written today survive the upgrade without rewriting.
16-slot nonce window — accounts can have up to 16 transactions in-flight out-of-order within the window. Decouples user-level submission from consensus-level execution ordering.
Transaction Lifecycle
1. Wallet constructs tx
2. Wallet → RPC: pyde_estimateAccess(tx) → returns gas_estimate + access_list
3. Wallet attaches access_list to tx
4. Wallet FALCON-signs tx hash
5. (Optional) Wallet encrypts signed_tx + access_list with epoch Kyber PK
6. Wallet submits: pyde_sendRawTransaction or pyde_sendRawEncryptedTransaction
7. RPC node validates wire format, forwards to nearest worker
8. Worker (plaintext) verifies sig, batches, gossips
9. Primary produces vertex, gossips
10. Commit fires (Mysticeti, sub-second target): anchor selected, subdag walked, canonical order emitted
11. (Encrypted) threshold decryption ceremony per encrypted tx (batches contain a mix of plaintext + encrypted txs)
12. wasmtime executes WASM modules in canonical order
13. JMT updates (dual-hash per node), state root signed
14. Finality declared (≥85 state root sigs)
Cross-Chain (Post-Mainnet)
Cross-chain interactions happen through a permissionless parachain layer — operators implement a Pyde-published specification, stake PYDE, follow protocol rules, and earn gas fees from contracts that call them via the cross_call! macro.
The protocol-level surface (cross_call! macro, HardFinalityCert primitive, unified gas model) is settled at v1 genesis. The actual parachain layer ships post-mainnet.
Three-Tier Node Model
| Tier | Stake | Committee Role | Earns |
|---|---|---|---|
| Committee validator | ≥10K PYDE (single-tier min) | Active (1 of 128) | Activity rewards + pool yield + inflation |
| Non-committee validator | ≥10K PYDE (single-tier min — same floor) | Stake-only, waiting selection | Pool yield + inflation |
| RPC node | None | None | Off-chain RPC fees (market-set) |
RPC providers (Infura/Alchemy analog) fit Tier 3 — no stake, no slashing risk.
Key Differentiators
| Ethereum | Solana | Sui | Pyde | |
|---|---|---|---|---|
| Post-Quantum | Migration 5+ years | No plan | No plan | Default at genesis |
| MEV resistance | Auction (PBS) | Proposer extracts | Some via Mysticeti | Structurally impossible |
| Finality | 12-15s | 400ms | 390ms | ~500ms |
| Commodity validator | Possible | No (12+ cores) | No (datacenter) | Yes (any validator awaiting committee selection) |
| Smart contract language | Solidity | Rust/Anchor | Move | Any wasm32 target (Rust, AssemblyScript, Go, C/C++) |
| Account abstraction | Retrofit (ERC-4337) | None native | Limited | Native (v2) |
| Cross-chain | Bridges ($3B+ hacked) | Bridges | Bridges | Permissionless parachain (v2) |
| ZK readiness | Retrofit ongoing | Limited | Limited | Architecture ready (v2) |
Next Chapters
- Chapter 3: Execution Layer — wasmtime runtime, host function ABI, Cranelift AOT, fuel-based gas, determinism boundary
- Chapter 4: State Model — JMT details, dual-hash strategy, PIP-2 clustering
- Chapter 5: Otigen Toolchain — the developer-facing binary (build, deploy, wallet, ABI extraction, per-language attribute declaration)
- Chapter 6: Consensus — full Mysticeti DAG specification
- Chapter 7: State Sync & Chain Halt — operational protocols
- Chapter 8: Cryptography — FALCON, Kyber, Poseidon2, DKG, threshold details
- Chapter 9: MEV Protection — threshold encryption + commit-before-reveal architecture
Chapter 3: Execution Layer
Pyde's execution layer is WebAssembly via wasmtime, with ahead-of-time compilation through Cranelift. Smart contracts and parachains run in sandboxed wasmtime instances, interacting with the chain through a fixed set of host functions that the engine implements in Rust.
This chapter covers the runtime architecture, the host function ABI surface, how compilation and caching work, gas metering, the determinism boundary, and the performance properties of the layer.
For context on why Pyde uses WebAssembly rather than a custom virtual machine, read the preface (The Pivot).
3.1 Why WebAssembly
WebAssembly was designed to be a compilation target: a small, well-specified, sandboxed instruction set that any source language can lower into and any runtime can execute deterministically. For Pyde, this gives us four properties simultaneously, none of which a custom VM could deliver without years of additional work.
-
Universal language support. Authors write contracts in whatever language they already know. Rust is the primary path; AssemblyScript, Go (via TinyGo), and C/C++ (via clang's
--target=wasm32) are first-class alternatives. The chain does not impose a language preference. -
Battle-tested runtime. Wasmtime is maintained by the Bytecode Alliance, used in production at Fastly, Microsoft, and Shopify, continuously fuzzed under adversarial workloads, and audited as a security-critical system. Pyde inherits this hardening at zero engineering cost.
-
Strong sandbox. WebAssembly's linear memory model and structured control flow eliminate entire categories of vulnerabilities (buffer overflows, control-flow hijacks, type confusion). The validation step at module load rejects any malformed binary before it can run. Importing forbidden functions (network, filesystem, threads) is gated at deploy time.
-
ZK-ready path. Active research on zero-knowledge proving of WebAssembly execution (zk-WASM) is converging on practical provers within a multi-year horizon. Pyde's contract bytecode is positioned to benefit from this without re-tooling — when zk-WASM provers mature, they slot in as an attestation layer over execution that has already happened.
The price for these properties: a small overhead on the order of 5-15% relative to a hand-tuned custom VM on tight compute loops, vanishing entirely for storage-bound workloads where the VM is not the bottleneck. The performance section at the end of this chapter quantifies this with real numbers.
3.2 Runtime Architecture
Execution sits inside the wasm-exec crate of the engine workspace. The crate exposes a single WasmExecutor type that owns the wasmtime engine, the compiled-module cache, and the host function bindings. The transaction pipeline calls into WasmExecutor per invocation; the executor handles the rest.
┌────────────────────────────────────────────────────────────┐
│ Engine transaction pipeline │
│ (mempool → Block-STM scheduler [with access-list prefetch] │
│ → execution dispatch) │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌───────────────┐
│ WasmExecutor │ ← single per node, owned by node
└──────┬────────┘
│
┌─────────────┼──────────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌─────────────────┐
│ wasmtime │ │ Module │ │ Host functions │
│ Engine │ │ cache │ │ (host_fns.rs) │
│ (Crane- │ │ (per- │ │ — sload │
│ lift) │ │ contract) │ │ — sstore │
└─────────┘ └──────────┘ │ — transfer │
│ — emit_event │
│ — threshold_* │
│ — hash_* │
│ — cross_call │
│ — ... │
└─────────────────┘
│
▼
┌─────────────────┐
│ JMT state, fee │
│ accounting, │
│ event log, etc. │
└─────────────────┘
WasmExecutor responsibilities:
- Hold the wasmtime
Engine(singleton, configured at startup with deterministic feature flags). - Cache compiled
Modules by contract address (compile once, reuse across invocations). - Instantiate per-invocation
Stores with isolated linear memory and the current execution context. - Wire host function calls through the linker.
- Track fuel consumption (gas).
- Handle trap conditions and propagate them as transaction failures.
Engine configuration (set once at node startup):
#![allow(unused)] fn main() { let mut config = wasmtime::Config::new(); config.strategy(wasmtime::Strategy::Cranelift); config.cranelift_opt_level(wasmtime::OptLevel::Speed); config.consume_fuel(true); config.epoch_interruption(true); // Determinism enforcement: config.cranelift_nan_canonicalization(true); config.wasm_threads(false); config.wasm_simd(false); config.wasm_relaxed_simd(false); config.wasm_reference_types(false); config.wasm_bulk_memory(true); // safe, deterministic, useful config.wasm_multi_memory(false); config.wasm_memory64(false); config.wasm_function_references(false); config.wasm_gc(false); config.wasm_component_model(false); // (No WASI imports allowed; not enabled at all.) }
This config produces deterministic execution suitable for consensus: every validator running the same module on the same input produces bit-identical state changes and identical fuel consumption.
3.3 The Host Function ABI
Smart contracts cannot directly access state, signatures, or anything outside their sandbox. They reach the chain through host functions — Rust functions registered with wasmtime's linker that contracts call by name. The full set of host functions is the Host Function ABI, versioned and documented in the canonical Host Function ABI v1.0 Specification.
This section gives the conceptual surface; the spec gives the binary signatures.
Storage:
sload(slot_ptr, out_ptr, out_max_len) -> i32— read a slot. Slot keys are 32 bytes (Poseidon2 of the contract address ‖ logical slot ID); slot values are variable-length raw bytes, up toMAX_STORAGE_VALUE_BYTES = 16 KB. Caller passes a max length and an out-pointer; host writesmin(actual, out_max_len)bytes and returns the actual length (orSLOAD_MISSING = -1for a never-written slot).sstore(slot_ptr, val_ptr, val_len) -> ()— write a slot.val_lenis arbitrary up to the 16 KB cap; exceed it and the host fn traps. Costs areGAS_SSTORE_BASE = 5_000 + 32/bytevalue (the per-byte component is what makes large writes proportionally expensive).sdelete(slot_ptr) -> ()— explicitly delete a slot (lower cost thansstore; no refund in v1, per Chapter 10).
Why variable-length values, not EVM-style 32-byte words. Pyde isn't word-oriented at the VM level — WASM operates on linear memory, not 256-bit words. Forcing slot values into 32 bytes would (a) require contracts to manually pack/unpack any non-uint256 data, and (b) burn one slot per logical field regardless of size, blowing up state-tree node count for the common case of small structs. The variable-length model lets a contract Borsh-encode an entire small struct into one slot (e.g. a Position { trader, size, entry, leverage } at ~80 bytes → one slot, one read, one decode) — closer to a key-value store than a word array. For larger logical values, contracts use standard chunking patterns: slot[H(base ‖ i)] = chunk_i for chunked sequential data, or slot[H(base ‖ key)] = value for mapping-style access. The 16 KB cap is a RocksDB write-amplification budget (per-slot write costs scale with size; >16 KB starts to hurt LSM compaction); it's a chain-spec parameter, tunable via a future PIP if real workloads demand it.
Balances and transfers:
balance(addr) -> u128— read an account's PYDE balance.transfer(to_addr, amount)— move PYDE from the caller toto_addr. Fails if insufficient balance.
Execution context:
caller() -> addr— the address that invoked the current call.origin() -> addr— the externally-owned address that initiated the transaction. (Deliberately distinct fromcaller()to avoid thetx.originfootgun from Ethereum.)wave_id() -> u64,wave_timestamp() -> u64.chain_id() -> u64.
Events:
emit_event(topic, data)— append a 32-byte topic + opaque bytes payload to the transaction's event log. Each event is buffered in the current overlay (per-tx, per-cross-call); reverted (sub-)calls' events are discarded. At wave commit, all surviving events are committed viaevents_root(Merkle tree) +events_bloomin the wave commit record. Recommended encoding fordatais Borsh; topics are typicallyBlake3(canonical_event_signature). Full storage / indexing / subscription mechanics: see Host Function ABI Spec §15.
Hashing primitives:
hash_keccak256(input) -> hash32— for compatibility with cross-chain interfaces.hash_blake3(input) -> hash32— fast general-purpose hashing.hash_poseidon2(input) -> hash32— ZK-friendly hashing (used in state commitments).
Post-quantum cryptography:
threshold_encrypt(plaintext) -> ciphertext— encrypt a payload under the current committee's threshold key. Available to parachains only.threshold_decrypt(ciphertext) -> plaintext— combine pre-collected committee shares to decrypt. Available to parachains only.falcon_verify(pubkey, message, signature) -> bool— verify a FALCON-512 signature.
Cross-contract calls:
cross_call(target, fn_name, calldata, value, gas_limit, ...)— synchronous call into another contract. Sub-call runs in a nested overlay; merges on success, discards on trap.cross_call_static(target, fn_name, calldata, gas_limit, ...)— view-only sub-call. Free for the caller (only the 50-gas dispatch base charged); bounded by a per-callVIEW_FUEL_CAP(default 10M fuel ≈ 3ms commodity).delegate_call(target, fn_name, calldata, gas_limit, ...)— execute target's code in the caller's storage context.self_address()andcaller()preserve outer-call identity. For proxy / upgradeable patterns.
Randomness:
beacon_get() -> hash32— current wave's committee-derived VRF beacon (XOR of all members' beacon shares). Deterministic across validators, publicly readable.
Gas:
consume_gas(amount)— explicit metering for operations the runtime cannot price automatically (used by binding generators for collection-traversal patterns).
Forbidden by design:
- Network calls (any kind).
- Filesystem access.
- System clock (use
wave_timestampinstead — deterministic). - Non-deterministic entropy (use a VRF-based host function when randomness is needed).
- Direct RocksDB access (everything routes through
sload/sstore).
The deploy-time validator rejects any WASM module whose import section references functions outside this allowlist. Hard-enforced.
3.4 Compilation and Caching
The wasmtime engine compiles WebAssembly bytecode to native machine code via Cranelift. Compilation is expensive (tens to hundreds of milliseconds per contract); execution after compilation is fast. The cache strategy makes this acceptable.
Compilation lifecycle:
Deploy time
│
├─ Wasm bytes submitted with deploy transaction
├─ Engine validates bytes (wasmtime::Module::validate)
├─ Engine rejects forbidden imports
├─ Engine compiles bytes via Cranelift → Module
├─ Engine serializes Module to bytes (Module::serialize)
├─ Engine stores both source bytes AND serialized Module in state
└─ Contract is live
Subsequent invocations
│
├─ Engine looks up contract address in module cache
│ ├─ Hit: use cached Module immediately
│ └─ Miss: read serialized Module from state, deserialize, cache it
├─ Engine creates per-invocation Store with execution context
├─ Engine instantiates Module against Store (sub-millisecond)
└─ Engine calls the entry function
Cache properties:
- In-memory cache keyed by contract address.
- LRU-style eviction with a configurable size budget (default ~256 modules resident).
- Serialized modules persist on disk so cold validators warm quickly.
- On contract upgrade, the cache entry is invalidated; the new module is compiled and cached on next use.
Per-contract compilation cost (measured on commodity hardware against PVM-era proxies; WASM-era numbers to be re-measured):
- A simple contract (~100 instructions): ~10ms.
- A medium contract (~1000 instructions): ~50-100ms.
- A large contract (~10000 instructions): ~500ms-1s.
These costs are paid once per contract per node restart, then amortized across all subsequent invocations.
3.5 Gas Metering
Pyde uses wasmtime's fuel mechanism for gas accounting. Fuel is a per-execution budget; every WebAssembly instruction consumes a configurable amount of fuel, and execution traps when fuel reaches zero. Host function calls also consume fuel manually (charged by the host based on operation cost — sstore is heavier than add, for example).
Gas-to-fuel mapping: At node startup, the engine establishes a deterministic mapping from gas units (the chain-level metering unit) to wasmtime fuel units. The mapping accounts for:
- Per-instruction baseline cost (each WASM instruction costs a fixed amount of fuel).
- Per-host-function cost (specific to each host function, defined in the ABI gas table).
- Per-byte storage costs (
sloadreads,sstorewrites, allocation surcharge for new slots). - Per-byte event emission cost.
A transaction declares its gas budget at submission; the engine converts that to fuel and runs the contract with that fuel limit. The fuel actually consumed is converted back to gas for the transaction receipt.
Why fuel and not opcode-counting: Fuel is built into wasmtime's Cranelift backend. Every basic block is instrumented to decrement a fuel counter; when the counter goes negative, execution traps with an out-of-fuel error. The instrumentation is efficient enough not to dominate execution time. Implementing custom opcode-counting on top of wasmtime would be slower and add maintenance burden for no functional gain.
Charging model — no refunds in v1:
The ingress check confirms balance ≥ gas_limit × base_fee, but only gas_used × base_fee is actually debited at execution time. Unused fuel costs the sender nothing — it is never debited and therefore never refunded. Pyde v1 has no operation-level gas refunds either (no sstore_refund, no sdelete refund). See Chapter 10 §10.1 for the full charging pipeline and the EIP-3529 reasoning.
3.5b Per-Transaction Execution Isolation
Every transaction executes against an overlay layered on top of the shared DashMap state cache. The overlay isolates the tx's writes and its emitted events so a revert can throw them away without affecting other txs in the same wave.
Per-tx isolation:
Before tx execution:
tx_overlay: {
state_writes: HashMap<SlotHash, Vec<u8>>,
events: Vec<EventRecord>,
} = empty
During execution:
Reads (state):
1. check tx_overlay.state_writes (any writes this tx made)
2. check dashmap (prior committed-in-this-wave writes from other txs)
3. check state_cf (current persistent state on disk)
Writes (state):
go into tx_overlay.state_writes only (not dashmap yet)
emit_event:
append to tx_overlay.events only
On successful completion:
merge tx_overlay.state_writes into dashmap (marking entries Dirty)
append tx_overlay.events to the wave's canonical events list
generate success receipt
drop tx_overlay (memory freed)
On trap (revert):
discard tx_overlay entirely — state AND events
state unchanged in dashmap
no events emitted to the wave's list
generate revert receipt with reason
sender still pays gas_used × base_fee (see Chapter 10)
Events follow the same merge/discard discipline as state writes. A reverted (sub-)call's events are discarded along with its state writes — the chain never sees events from a path that didn't commit. The wave's final events list (committed via events_root + events_bloom; see Host Function ABI Spec §15) is the topmost overlay's events buffer at wave commit time.
Why no separate undo log: failed writes never landed in shared state. Dropping the overlay throws them away. Simpler than journaled undo.
Nested cross-calls: when tx A calls contract B which calls contract C, each call gets its own overlay layered on top:
A's overlay
↓
B's overlay (reads check B's overlay first, then A's, then dashmap, then state_cf)
↓
C's overlay (reads check C's, then B's, then A's, then dashmap, then state_cf)
Inner call succeeds → merge inner overlay into parent overlay
Inner call traps → drop inner overlay; parent continues
Outer tx traps → drop outer overlay (including all merged inner state)
This is standard transactional-memory layering. wasmtime's host functions are aware of the active overlay and route reads/writes through it.
Memory bounds on the overlay
The overlay can grow during a tx, but is bounded by two factors:
-
Gas budget. Every write into the overlay charges fuel via
sstore. A tx withgas_limit = 10_000_000can write at most ~50K slots (varying by slot size). Author can't write infinitely without paying. -
Linear memory cap. wasmtime's per-instance linear memory is capped (64MB default, configurable per chain release). Even if gas were infinite, the WASM module can't allocate beyond this cap.
Together: a tx can use up to (gas_limit / sstore_cost) × value_size of overlay memory, but capped by linear memory. We don't impose a separate "tx overlay memory cap" — gas + wasmtime config bound it.
3.6 The Determinism Boundary
For consensus to hold, every validator must produce bit-identical state changes when executing the same transaction. This requires deterministic execution at every layer.
Deterministic-by-default in WebAssembly:
- Integer arithmetic (well-specified, no platform-dependent behavior).
- Memory operations (bounds-checked, no undefined behavior).
- Control flow (structured, no goto, no jump tables that vary by platform).
Determinism risks WebAssembly admits, which we disable:
- Floating-point: most operations are deterministic by IEEE-754, but NaN bit patterns can vary. We enable
cranelift_nan_canonicalizationso NaN outputs are canonicalized identically across all validators. - Threads: non-deterministic by definition; we disable the threads proposal.
- SIMD: most SIMD is deterministic, but certain operations (relaxed SIMD) are not. We disable both the SIMD and relaxed-SIMD proposals for now; we may re-enable a deterministic-only SIMD subset in a future version.
- Reference types, GC, function references, component model: complexity surface we don't need yet, disabled.
Determinism risks the runtime introduces, which we control:
- Module compilation may produce different machine code on different platforms (different architectures, different Cranelift versions). We pin the wasmtime version per chain release and require validators to upgrade in coordinated forks. Cached serialized modules are not portable across versions.
- Fuel consumption per host function is defined in the gas table, identical across validators.
What contracts cannot observe:
- Wall-clock time. Use
wave_timestamp(deterministic, set by consensus). - True randomness. Use a VRF-derived host function when randomness is required (deterministic per block, unpredictable beforehand).
- The host machine. No CPU info, no OS info, no environment access.
Deploy-time validation: Every contract's WASM is validated at deploy time against the determinism rules. Any module that imports a forbidden function, uses a disabled feature, or fails wasmtime's structural validator is rejected. The validation gate is non-negotiable — it prevents bad code from ever reaching consensus.
3.7 State Access from the Author's Perspective
Host functions are low-level: they take pointers + lengths into WASM linear memory and return raw bytes. Contract authors write the slot derivation themselves in their source language, following the PIP-2 slot layout described in Chapter 4: State Model. The otigen toolchain does NOT generate code; authors write a small helper module (or copy one from a canonical example) that turns ergonomic API calls into the right pyde_storage_read / pyde_storage_write host calls.
The pattern (in Rust):
#![allow(unused)] fn main() { // Author writes (or copies from the canonical example): // 1. Host function imports (one-time declaration): extern "C" { fn pyde_storage_read(slot_hash_ptr: *const u8, slot_hash_len: usize) -> i64; fn pyde_storage_write(slot_hash_ptr: *const u8, slot_hash_len: usize, value_ptr: *const u8, value_len: usize); fn pyde_poseidon2(input_ptr: *const u8, input_len: usize, out_ptr: *mut u8); } // 2. Contract-name prefix, derived once at startup: // (Rust patterns include lazy_static!, OnceCell, const fn — author's choice.) fn contract_addr_prefix() -> &'static [u8; 16] { /* ... */ } // 3. Discriminator constants from otigen.toml [state] section: const BALANCE_DISC: u8 = 0; // matches [state] balance.disc // 4. Slot derivation following PIP-2 layout (address[..16] || hash(disc||key)[..16]): fn balance_slot(addr: &[u8; 32]) -> [u8; 32] { let mut slot = [0u8; 32]; slot[..16].copy_from_slice(contract_addr_prefix()); let mut input = [0u8; 33]; input[0] = BALANCE_DISC; input[1..].copy_from_slice(addr); let mut inner = [0u8; 32]; unsafe { pyde_poseidon2(input.as_ptr(), input.len(), inner.as_mut_ptr()); } slot[16..].copy_from_slice(&inner[..16]); slot } // 5. Ergonomic accessor (author writes this small wrapper): fn read_balance(addr: &[u8; 32]) -> u128 { let slot = balance_slot(addr); let mut value = [0u8; 32]; unsafe { /* call pyde_storage_read, copy into value */ } u128::from_le_bytes(value[..16].try_into().unwrap()) } }
Where the hashing happens:
- The contract-name prefix (
contract_addr_prefix()) is computed once at startup using whatever caching pattern the author's language provides. Rust authors useOnceCell/lazy_static!/ aconst fnif possible. AssemblyScript uses a module-level constant initializer. Go usesinit(). C uses astatic constarray initialized at first call. After the first computation, it's free. - The discriminator (
BALANCE_DISC = 0) is a compile-time constant — never re-hashed. - The dynamic part (the
addrargument) is hashed at runtime — onepyde_poseidon2call per slot reference. That's the irreducible cost.
This is the same end-state as if otigen were generating bindings — same hash count at runtime, same memory layout, same gas profile. The difference: the author owns the code, can inspect it, can audit it, can replace pieces with optimized hot-path versions, and isn't dependent on a chain-team-maintained code generator. The canonical example projects in pyde-net/otigen ship one workable pattern per supported language as a starting point.
The same pattern adapts to AssemblyScript, Go (TinyGo), and C/C++ — each language has its own idioms for module-level constants, lazy initialization, and FFI to host functions. See pyde-net/otigen/examples/ for a working version in each language.
3.8 Performance Characteristics
The honest numbers, measured against PVM-era proxies (WASM-era numbers will replace these as benchmarks are re-run):
Compute-bound workloads (tight ALU loops):
- Wasmtime AOT runs within roughly 80-95% of native code on most workloads. Measured benchmarks on PVM-era code showed AOT throughput around 2.9 billion instructions per second for ALU dispatch; wasmtime-AOT sits in the same range because both use the same Cranelift backend.
- Interpreted execution (cold cache, no AOT yet) runs at roughly 10-30% of native. Pyde's WASM interpreter path is similar in throughput to the previous PVM interpreter measured at ~279 million instructions per second.
Storage-bound workloads (typical real-world smart contracts):
- The AOT-vs-interpreter advantage collapses. Token transfers measured around 231K tps interpreted and 243K tps AOT — essentially identical, because RocksDB IO dominates and neither the interpreter nor the AOT can speed it up.
- This is the workload shape that actually determines blockchain throughput. The VM choice barely affects it.
Module compilation:
- Sub-millisecond for small contracts.
- ~1 second for the largest realistic contracts.
- Paid once per contract per node startup, then cached forever.
End-to-end TPS: The v1 honest throughput target on commodity validator hardware (for both the plaintext and encrypted regimes) is to be established by the multi-region performance harness — it comes from the full-chain harness (consensus + execution + state + network), not from VM microbenchmarks alone. The VM is approximately the fifth-most-important contributor to that number, behind signature verification, network bandwidth, consensus latency, and disk I/O.
The publishing discipline applies: published TPS numbers are derived conservatively from sustained measurement under realistic conditions, never from microbenchmark peaks or lab extrapolations.
3.9 Failure Modes and Traps
When a contract execution fails, it traps. The transaction reverts, no state changes persist, the sender pays gas up to the trap point.
Trap conditions:
- Out of fuel — exceeded the transaction's gas budget.
- Out of bounds — WASM linear memory access outside allocated range.
- Integer overflow (when checked arithmetic is requested by host function gating).
- Forbidden import attempt — caught at deploy, not at runtime; deploy fails instead.
- Stack overflow — wasmtime's configurable stack limit reached.
- Unreachable — the WebAssembly
unreachableinstruction was executed (typically Rust'spanic!()lowers to this). - Host function error —
sstoreto a write-locked slot,transferwith insufficient balance, etc.
Engine-level protections:
- Per-call wall-clock timeout (epoch interruption). Prevents a buggy contract from spinning forever even if fuel accounting is somehow bypassed.
- Per-call linear memory limit (capped well below host memory).
- Per-call stack depth limit.
Trap conditions are reported in transaction receipts as structured error codes, queryable by clients.
3.9b Native Transactions vs WASM Calls
Not every transaction invokes wasmtime. Pyde has a small set of native transaction types that the engine executes directly, without WASM overhead.
Native tx types (no wasmtime invocation)
- Transfer — move PYDE between two accounts; ~21,000 gas; engine handles balance update directly
- ValidatorRegister — stake-account-binding system tx
- ValidatorUnbond — initiate unbonding
- ValidatorRotateKey — FALCON key rotation
- ValidatorUnjail — exit jailed state after grace period
- Multisig — treasury / governance multisig spend
- Slashing — system-emitted from evidence
These all bypass wasmtime and execute as Rust code in the engine. They're cheaper, faster, and don't carry the per-tx WASM instantiation cost.
WASM tx types (wasmtime executes)
- ContractCall — invoke a function on a deployed WASM contract
- ContractDeploy — register new WASM bytes + ABI as a contract
- ParachainCall — invoke a function on a deployed parachain WASM (cross-call routing)
These instantiate the target module via wasmtime, call the entry function, execute under the per-tx overlay, and produce a receipt.
Why split this way
- Performance. Simple transfers don't need a sandbox or fuel metering — they're trivially provable state updates.
- Gas predictability. Native transfers have a fixed gas cost (~21K) known in advance; no fuel-counting needed.
- Common-case optimization. Simple value transfers are the most common tx type on any chain. Avoiding WASM overhead per-transfer materially improves end-to-end TPS for high-volume payment workloads.
WASM contracts that need to move value internally still call pyde_transfer as a host function, which does the same balance-update logic the native transfer does. Authors don't have to choose; the chain serves both paths.
3.10 Contract Lifecycle
Author writes contract → otigen build → .wasm + ABI
│
▼
Author runs otigen deploy
│
├─ Pays registration fee for name (ENS-style, see Account Model chapter)
├─ Pays owner deposit (forfeit on misbehavior)
└─ Submits deploy tx with .wasm bytes
│
▼
Engine validates module (validator, deterministic-features gate, import allowlist)
│
▼
Engine compiles via Cranelift, caches serialized module
│
▼
Engine writes (contract_address → wasm_hash, serialized_module, owner, deposit) to state
│
▼
Contract is live; callable by anyone holding its address or name
Upgrade path mirrors deploy but routes through governance for parachain contracts. Smart contracts (non-parachain) follow a simpler owner-only upgrade flow with grace periods to give users time to verify the new code.
3.11 Where the Code Lives
The WASM execution layer is implemented post-pivot in a fresh engine workspace that does not exist yet. The pre-pivot pvm and aot crates are preserved in pyde-net/archive for historical reference and bench comparison. The table below names the components and their planned crate layout once the fresh engine repo is cut.
| Component | Planned crate / file (post-pivot) |
|---|---|
| WasmExecutor entry point | wasm-exec/src/lib.rs |
| Host function implementations | wasm-exec/src/host_fns.rs |
| Module cache | wasm-exec/src/module_cache.rs |
| Fuel-to-gas mapping | wasm-exec/src/gas_meter.rs |
| Validation gate | wasm-exec/src/validate.rs |
| Deploy-tx processing | tx/src/deploy.rs |
| State binding code generators (per language) | otigen repo (otigen/crates/codegen-*) |
| Host Function ABI specification | companion/HOST_FN_ABI_SPEC.md |
3.12 Open Questions
These are tracked as planned work and resolved as the execution layer matures:
- Re-enabling deterministic SIMD. Pyde currently disables SIMD entirely. A deterministic SIMD subset (excluding relaxed operations) would benefit crypto-heavy contracts. Pending implementation work and conservative validation.
- WASM module hash-content-addressing. Two contracts with identical WASM bytes could share a single compiled module entry. Optimization opportunity; not blocking.
- zk-WASM proving integration. When zk-WASM provers reach production quality, slot one in as an optional execution attestation layer. Tracked as a v2/v3 direction.
- Hot-reload of compiled modules across version pins. Currently a wasmtime version bump invalidates the cache; coordinated upgrades are required. Hot-reload research may relax this.
3.13 Reading on
- Chapter 4: State Model — how
sloadandsstorereach the JMT. - Chapter 5: Otigen Toolchain — how authors interact with the execution layer through the developer tool.
- Chapter 6: Consensus — how execution outcomes commit to the chain.
- Chapter 8: Cryptography — what FALCON, Kyber, and Poseidon2 actually do, and how the host functions expose them.
- Preface: The Pivot — why the execution layer is WebAssembly rather than a custom VM.
Chapter 4: State Model
Every blockchain is a replicated state machine. Transactions transform state; consensus ensures every honest node agrees on the result. The quality of the state model decides how fast you commit, how cheap you sync, and how well you parallelize execution.
Pyde stores all state in a Jellyfish Merkle Tree (JMT), persisted in RocksDB, with hybrid hashing: Blake3 on high-volume native paths, Poseidon2 on ZK-bearing paths. The state commitment is dual-rooted — Blake3 for fast native verification by committee and validators, Poseidon2 for future ZK light clients and validity proofs.
The JMT replaces the fixed-depth Sparse Merkle Tree the project initially shipped — a swap made because JMT's radix-16 path compression delivers roughly 40× faster commits. Hybrid hashing was adopted post-pivot once the performance cost of running Poseidon2 over every internal JMT node became clear; Blake3 is ~50× faster on commodity CPUs without sacrificing the ZK-friendly properties where they matter (state root, address derivation, FALCON-sig-hashing inside circuits).
4.1 The Jellyfish Merkle Tree
The JMT is a radix-16 path-compressed Merkle tree. Each internal node has up to 16 children (one per nibble), and runs of single-child nodes are compressed into a single edge labelled with the shared key prefix. Empty subtrees are not materialized.
Why JMT over a fixed-depth Sparse Merkle Tree?
| Property | Fixed-depth SMT (256 levels) | JMT (radix-16, compressed) |
|---|---|---|
| Node hashes per update | 256 | depth-of-key (typ. 8–14) |
| Empty subtree storage | implicit (precomputed) | implicit (no materialize) |
| Update batching | per-key | bulk via update_all |
| Throughput (commits) | baseline | ~40× faster |
| Proof size | fixed (256 sibling hashes) | variable (typ. 8–14) |
| Non-existence proofs | empty leaf hash | path divergence proof |
The headline number — 40× faster commits — was the deciding factor. JMT removes the per-key 256-Poseidon2 cost, replacing it with a path that follows the actual key density in the tree.
The implementation lives in crates/state/src/jmt_store.rs. The persistent
wrapper exposes a small surface:
#![allow(unused)] fn main() { PersistentJMT { fn insert(key: H256, value: Vec<u8>) -> ... fn get(key: H256) -> Option<Vec<u8>> fn update_all(updates: &[(H256, Option<Vec<u8>>)]) -> ... fn root() -> H256 fn delete(key: H256) -> ... fn is_empty() -> bool } }
A HybridJmtHasher adapter implements the jmt::SimpleHasher trait,
delegating internal node hashes to Blake3 (the high-volume path) and
exposing Poseidon2 for state-root and address-derivation paths. The
JMT internals use Blake3; the snapshot manifest and ZK-bearing exports
use Poseidon2. Both roots are computed and signed (Chapter 6).
4.1b Two-Table Architecture: state_cf + jmt_cf
Pyde maintains state in two RocksDB column families, each optimized for a different access pattern:
┌────────────────────────────────────────────────────────────────────────┐
│ state_cf — flat key-value index for live reads │
│ │
│ key = slot_hash (32 bytes, PIP-2 layout) │
│ value = current slot value (raw bytes) │
│ │
│ O(1) point lookup. Updated on every state change. │
│ Used by: live execution path (sload), RPC queries, range scans. │
└────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ jmt_cf — versioned tree structure for proofs + state root │
│ │
│ key = NodeKey(version: u64, NibblePath) │
│ value = JmtNode { children_fingerprints[], value_bytes (if leaf) } │
│ │
│ O(depth) walk for proofs. Updated at every wave commit. │
│ Used by: state-root computation, Merkle proofs for light clients, │
│ historical state queries (on archive nodes). │
└────────────────────────────────────────────────────────────────────────┘
Why two tables instead of one:
The JMT alone can serve every read, but each read is O(depth) — typically 6-8 RocksDB gets to walk from root to leaf. For live execution at thousands of TPS, that's too expensive.
state_cf keeps a flat denormalized index of the current value for every slot. A single get returns the value. PIP-2's clustered slot_hash layout keeps state_cf entries spatially clustered by contract, so range scans and multigets stay cheap.
The JMT structure is still maintained alongside, because it's needed for:
- State-root computation: hash up from leaves to root, deterministically, across all validators
- Merkle proofs: light clients verify
(value, proof) → state_rootwithout holding full state - Versioned reads: archive nodes serve historical state by walking older JMT versions
The read path:
fn read_slot(slot_hash) -> Option<Bytes>:
1. dashmap.get(slot_hash) ← PIP-4 in-memory cache (most live reads)
2. state_cf.get(slot_hash) ← ONE disk read (cache miss path)
Total: one disk get, sometimes amortized to zero.
The JMT is not in the live read path. Reads use state_cf. The JMT is reached only for proofs or for state-root computation at commit time.
The write path (at wave commit):
fn commit_wave(dirty_changes: Vec<(SlotHash, Bytes)>):
1. For each (slot_hash, new_value) in dirty_changes:
jmt.update(slot_hash, new_value, new_version)
→ JMT recomputes leaf_hash + internal hashes up the affected path
state_cf.put(slot_hash, new_value)
2. new_state_root = jmt.root_hash(new_version)
3. Both writes happen in a single RocksDB WriteBatch (atomic).
The two tables stay in lockstep. They are never out of sync because every write touches both atomically.
Cost of duplication: roughly 2× storage for the state itself (the leaves' values appear in both state_cf and the JMT's leaf records). This is the trade-off — extra storage in exchange for O(1) live reads while still preserving authenticated proofs.
Retention split:
| Node tier | state_cf | jmt_cf |
|---|---|---|
| Pruned validator | Current state only | Latest version only (older GC'd) |
| Archive node | Current state | All historical versions |
| Light client | None | Just state_root from WaveCommitRecords |
4.1c Events Storage: events_cf + Indexes
State is not the only thing the chain stores. Events emitted via pyde::emit_event (see Chapter 3 §3.3 and Host Function ABI Spec §15) live in three additional column families parallel to state_cf + jmt_cf:
events_cf (primary, ordered by wave)
key: wave_id (8 BE) || tx_index (4 BE) || event_index (4 BE)
value: borsh_encode(EventRecord)
events_by_topic_cf (index)
key: topic (32) || wave_id (8 BE) || tx_index (4 BE) || event_index (4 BE)
value: () -- empty; key carries lookup info
events_by_contract_cf (index)
key: contract_addr (32) || wave_id (8 BE) || tx_index (4 BE) || event_index (4 BE)
value: ()
Atomicity: at every wave commit, the engine writes one RocksDB WriteBatch containing updates to state_cf + jmt_cf + events_cf + events_by_topic_cf + events_by_contract_cf + the wave commit record. Either all five land or none does.
On-chain commitment: each wave commit record carries two summaries of the wave's events:
events_root(Blake3) — binary Merkle tree over canonical-ordered events, suitable for inclusion proofs.events_bloom(256-byte, 2048-bit, 3-hash) — probabilistic summary for cheap "any event matching X in this wave?" checks.
Both are threshold-signed as part of the wave's HardFinalityCert, so light clients verify event inclusion identically to how they verify state.
Retention:
| Node tier | events_cf + indexes |
|---|---|
| Archive node | All events, forever |
| Pruned validator | Last 90 days |
| Committee validator | Last 30 days |
| Light client | None (verifies inclusion proofs against signed events_root) |
Pruning is in lockstep across all three event column families.
For query semantics (pyde_getLogs), subscriptions (pyde_subscribe), and the Borsh-recommended event encoding, see Host Function ABI Spec §14–§15.
4.2 Hybrid Hashing: Blake3 + Poseidon2
Pyde uses two hashes in different layers, chosen for what each is best at:
| Hash | Speed (commodity CPU) | ZK-friendly | Where used |
|---|---|---|---|
| Blake3 | ~3 GB/s | No (huge circuit) | JMT internal nodes, batch hashes, vertex hashes, gossip de-dup, RocksDB keys |
| Poseidon2 | ~60 MB/s | Yes (small circuit) | State root commitment, address derivation, FALCON sig hashing inside ZK circuits, threshold MAC |
The split rule: every hash that lives entirely off-chain or inside a trusted committee-signed structure can be Blake3. Every hash that may be exposed to a future ZK proof (state root, addresses, signature payloads) is Poseidon2.
Poseidon2 (Goldilocks)
Poseidon2 is the algebraic hash used everywhere in Pyde — the JMT, contract
storage-key derivation, transaction hashing, the threshold MAC, the VRF, and
the poseidon2 WASM host function. The parameter set (see Chapter 8 for full
detail):
| Parameter | Value |
|---|---|
| Field | Goldilocks (p = 2^64 - 2^32 + 1) |
| State width | 8 |
| Rate | 4 (256-bit absorb/squeeze) |
| Capacity | 4 |
| External rounds | 8 (4 + 4) |
| Internal rounds | 22 |
| S-box | x^7 |
| Output | 256 bits |
The hash is exposed as three primitives:
| Function | Use |
|---|---|
poseidon2_hash(bytes) | arbitrary input → 256-bit digest |
poseidon2_pair(left, right) | Merkle node hash (order-sensitive by design) |
poseidon2_many(&[Hash256]) | sponge over a variable-length array of hashes |
The _pair form is exposed for compatibility but JMT internal nodes use
Blake3 (blake3_pair); Poseidon2's _hash form is what storage-key
derivation, address derivation, and the poseidon2 WASM host function use; the
_many form is what the threshold scheme uses to combine epoch randomness
shares.
Blake3
Used in the high-volume paths where ZK-friendliness is irrelevant:
- JMT internal node hashes (hybrid-mode hasher)
- Batch hashes referenced from vertices
- Vertex hashes in the DAG
- Gossip message de-duplication keys
- RocksDB cache keys
Blake3 is configured in its default tree-hashing mode with 256-bit output. Native verification of a JMT inclusion proof against the Blake3 state root takes ~5-10 hash operations and completes in microseconds — fast enough that the snapshot manifest verification (Chapter 7) doesn't dominate sync time.
4.3 Account Storage Layout
Every account in crates/account/src/types.rs has a fixed layout:
#![allow(unused)] fn main() { struct Account { address: Address, // 32 bytes (Poseidon2 hash of FALCON pubkey) nonce: u64, // 8 bytes (sliding window base — see Chapter 11) balance: u128, // 16 bytes, in quanta (10^9 quanta = 1 PYDE) code_hash: H256, // 32 bytes (zero for EOAs) storage_root: H256, // 32 bytes (zero for empty contracts) account_type: AccountType,// 1 byte (EOA=0, Contract=1, System=2) auth_keys: AuthKeys, // variable (FALCON pubkey or multisig set) gas_tank: u128, // 16 bytes (sponsored-tx pool) key_nonce: u32, // 4 bytes (rotation counter) } }
Fixed portion: 141 bytes plus the variable auth_keys field.
The address is a 32-byte Poseidon2 hash. Three derivation paths exist:
EOA address = Poseidon2(falcon_public_key_bytes) // 897-byte FALCON pk
CREATE address = Poseidon2(deployer_address || nonce_bytes)
CREATE2 address = Poseidon2(0xFF || deployer_address || salt || code_hash)
The 32-byte length matches the natural Poseidon2 output (4 Goldilocks field elements ≈ 256 bits) and avoids the birthday-bound concerns of 20-byte truncated addresses at chain scale.
4.4 Storage Keys and Slots
Pyde uses a flat storage layout. Account fields and contract storage slots all live in the same JMT, distinguished by discriminator bytes in the key derivation.
The key derivation pattern is:
key = Poseidon2(account_address || discriminator || sub_key)
Some discriminators currently in use (defined in crates/state/src/keys.rs):
| Discriminator | Name | What it keys |
|---|---|---|
| 0x12 | SUPPLY | Total PYDE supply counter |
| 0x13 | TOTAL_BURNED | Cumulative fee burn counter |
| 0x14 | REWARDS_PER_STAKE_UNIT | Lazy-accrual per-stake-unit reward accumulator |
| 0x15 | ACTIVE_STAKE_WEIGHTED_TOTAL | Pool divisor (sum of stake × uptime; excludes exited/slashed) |
| 0x16 | VESTING | Per-account vesting schedule |
| 0x17 | VALIDATOR_SUBSIDY | (total_amount, end_wave) for streaming subsidy |
| 0x18 | AIRDROP_ROOT | Genesis airdrop Merkle root |
| 0x19 | AIRDROP_DEADLINE | Slot height after which sweep is allowed |
| 0x1A | AIRDROP_CLAIMED | Per-leaf-index claim bitmap |
| 0x1B | AIRDROP_EXPECTED_SUM | Genesis pool size invariant |
| 0x1C | MULTISIG_SIGNERS | Treasury multisig signer set (FALCON pks) |
| 0x1D | MULTISIG_THRESHOLD | Required signature count |
| 0x1E | MULTISIG_NONCE | Replay-protection counter for multisig actions |
| 0x1F | EMERGENCY_PAUSE_END_WAVE | End wave_id of an active emergency pause |
This flat scheme means a single Merkle path can prove any state claim — there
is no nested account-trie / storage-trie indirection (the classic
Patricia-trie pattern). One proof, one Poseidon2-walk to the root.
Contract storage layout
The otigen developer toolchain's state binding generator assigns slot
identifiers to storage fields declared in otigen.toml. Each contract
defines its state schema once and gets language-specific bindings that
encode the slot derivation as build-time constants. Single-value fields
lower to:
key = Poseidon2(contract_address, slot_index)
Maps lower to a doubled hash:
key = Poseidon2(contract_address, Poseidon2(slot_index, map_key))
Nested maps add another inner Poseidon2 per nesting level. This is the
machinery that makes self.balances[user_addr] a single Sload opcode in
the compiled bytecode.
4.5 The Block Witness
Pyde's block witness is the data needed to verify and re-execute a block from
scratch given only the previous state root. It lives in
crates/state/src/witness.rs:
#![allow(unused)] fn main() { pub struct BlockWitness { pub entries: Vec<WitnessEntry>, pub proof: SparseMerkleProof, // single batched proof pub pre_state_root: H256, pub post_state_root: H256, // populated by finalize_witness } }
The shape:
entries— every state slot the block touched, with its pre-execution value.proof— a single batched Merkle proof covering all entries againstpre_state_root. JMT supports batch verification, so the proof is asymptotically smaller thanlen(entries)independent paths.pre_state_root— the state root before this block executes (taken from the parent block's header).post_state_root— the state root after execution, set byset_post_state_root()orfinalize_witness()once the block is executed.
Critically, post_state_root is not auto-populated at witness generation
time. The witness is built before execution; the post-root is filled in
afterwards. is_finalized() returns false until that step happens.
The 1 MB witness size cap
A hostile transaction could theoretically force a witness containing millions of entries (e.g., touching deep, sparse storage paths). Pyde caps witness size hard:
#![allow(unused)] fn main() { pub const MAX_WITNESS_SIZE: usize = 1024 * 1024; // 1 MB }
verify_witnesses() rejects any witness exceeding this cap before doing the
work of proof verification. The block as a whole is rejected.
4.6 RocksDB Layout
The JMT and witness logic both persist through RocksDB
(JmtRocksStore in crates/state/src/jmt_store.rs). The key prefixes are:
| Prefix | Meaning |
|---|---|
0x10 | JMT internal nodes |
0x11 | Leaf values |
0x12 | Metadata (version counter, latest root) |
LRU caches sit in front of node and value reads (256k entries each, sized for the working set of an active validator). Compression is LZ4 for the L0–L1 levels and ZSTD for cold levels; the block cache is 512 MB and the memtable pool is 256 MB. These are tuned for the steady-state validator workload, not for peak burst sync.
Writes to consensus-critical state use WriteOptions::set_sync(true) (see
Chapter 6) — JMT updates do not, because the canonical truth is the chain
itself; on restart, a validator can rebuild any missing state from blocks.
4.7 The Wave-Application Pipeline
When a wave commits, the state pipeline runs in this order:
- Open a batch against the current JMT (the wave's
pre_state_root). - Prefetch every
(addr, slot)pair declared across the wave's tx access lists in one batchedstate_cf.multi_get(PIP-3). Returned values land in the dashmap (PIP-4) marked Clean. Access lists are prefetch hints only — they never partition the wave or affect correctness. - Execute every tx in parallel via the Block-STM scheduler: optimistic execute through an MVCC layer → validate against canonical tx_index order → cascade-invalidate + re-incarnate on conflict → fixpoint. The final state per slot is the highest-tx_index's last write.
- Apply the Block-STM finalize output to the batch as one ordered slot-write set.
- Distribute fees: 70% to the burn counter (
TOTAL_BURNEDdiscriminator), 20% to the epoch reward pool (distributed at epoch end by stake × uptime), 10% to the treasury account. - Commit the batch with
update_all. The new root ispost_state_root. - Set the WaveCommitRecord's
state_rootand emit the per-waveWaveCommitInputsfor the wave-committer.
The Block-STM correctness contract guarantees that two honest validators given the same canonical tx list + same pre_state_root produce bit-identical post_state_root and receipts, regardless of how many re-execution attempts each validator's scheduler needed to reach fixpoint. Attempt order, thread interleaving, and wall-clock duration can all differ; the final state cannot. Disagreement on post_state_root is a slashing-grade safety violation per Chapter 6 §12.
4.8 State Sync
A new node joining the network does not replay every block from genesis — at production TPS, full replay would take longer than the chain has existed. Pyde defines three sync modes (full spec: companion/STATE_SYNC.md, operational summary: Chapter 7):
-
Snapshot sync (default for new full nodes). Download a committee-signed
SnapshotManifest(~5 KB) carrying both Blake3 and Poseidon2 state roots plus chunk references. Verify ≥85 FALCON signatures. Download chunks (~4 MB each) in parallel from peers, verify each against the manifest, reconstruct the JMT, recompute the Blake3 root, compare. Then replay the tail blocks (≤ 8 epochs ≈ 24 hours of tx) to reach the current head. Total time on commodity (100 Mbps): ~40 minutes. -
Light client sync. Headers only + cared-about accounts via JMT inclusion proofs. ~600 KB/year for a typical wallet. Verifies FALCON signatures on the headers it receives.
-
Full sync (archive nodes). Replay every block from genesis. Slowest option; provides full historical state lookup for explorers / indexers.
Chain-of-trust bootstrap. A new node verifies the chain of snapshot
manifests from genesis forward: genesis hardcodes committee_0's pubkeys;
each subsequent epoch-boundary manifest is signed by the prior committee
and contains the next committee's pubkeys.
Weak-subjectivity checkpoints published by the foundation and reputable infrastructure providers let new nodes trust a recent checkpoint and skip the chain-of-trust walk. Beyond a one-epoch rollback window, contradicting a finality checkpoint is impossible without a hard fork.
4.9 What Is NOT in the State
A few things deliberately do not live in the JMT:
-
Receipts. Stored in an in-memory ring buffer (
crates/node/src/receipt_store.rs,MAX_RECEIPT_SLOTS = 10_000). At ~500 ms per commit, this is roughly 80 minutes of recent receipt history. Persistent receipt storage (archive-node mode) is tracked as post-mainnet hardening. -
Mempool contents. Encrypted transactions live in process memory, bounded per sender by the rate-limiting subsystem (10 tx/s, 100 concurrent per sender).
-
Consensus protocol state.
pending_votes,seen_proposals,seen_votes, and pending evidence live in their own RocksDB column under the consensus_store, withset_sync(true)writes — see Chapter 6. -
Finality checkpoints. Stored in the consensus_store with their own key (
FINALITY_CHECKPOINT_KEY), not in the JMT itself.
The line is drawn deliberately: the JMT holds canonical chain state that everyone agrees on. Operational state (consensus liveness, mempool ingress, receipt cache) lives outside the consensus root because it does not need to be globally agreed.
4.10 Summary
| Component | Choice |
|---|---|
| Tree structure | Jellyfish Merkle Tree (radix-16, path-compressed) |
| Internal-node hash | Blake3 (high-volume, native) |
| State root | Dual: Blake3 (native) + Poseidon2 (ZK-bearing) |
| Address-derivation | Poseidon2 (ZK exposure preserved) |
| Storage layout | Flat — single tree, discriminator bytes in keys |
| Address format | 32 bytes, Poseidon2 of the FALCON-512 public key |
| Account record size | 141 bytes fixed + variable auth_keys |
| Storage keying | Poseidon2(addr, slot) for values; doubled for maps |
| Witness format | Single batched JMT proof + entries + pre/post roots |
| Witness size cap | 1 MB (rejected at verification time) |
| Persistence | RocksDB with LRU node and value caches |
| Block-app commit cost | ~40× faster commits than the prior fixed-depth SMT design |
The next chapter covers the developer toolchain (otigen) that sits on top
of this state model — how a contract's [state] declaration in otigen.toml
becomes the slot identifiers the JMT actually sees, via language-specific
state binding generators that pre-compute slot prefix constants at build time.
Chapter 5: Otigen Toolchain
otigen is Pyde's developer toolchain — a single binary that scaffolds projects (from a language template or canonical example), validates the author's WASM build, runs behaviour tests against the compiled .wasm, generates the ABI from otigen.toml, packages the deploy bundle, manages FALCON-512 keystores, and handles on-chain lifecycle commands (deploy, upgrade, pause, kill, inspect, verify, console).
What otigen deliberately does NOT do: it does not compile WASM, it does not generate code, it does not interface with any language's build pipeline. Authors run their own cargo build / asc / tinygo build / clang --target=wasm32 and otigen checks the result. This keeps the toolchain minimal and language-agnostic, and lets authors keep their full native toolchain experience.
The name carries forward from an earlier design phase, when Otigen was Pyde's domain-specific smart-contract language. The language is retired; the name now describes the role it occupies best — the lightweight verifier and packager that makes WebAssembly deployment on Pyde coherent without forcing authors out of their language ecosystems. See The Pivot for the full story.
This chapter covers the toolchain's design, the subcommand surface, the otigen.toml schema, the per-language workflow, build verification, attributes, deploy/upgrade, wallet, behaviour tests, and the console.
For the underlying execution layer that contracts run on, read Chapter 3: Execution Layer. For the host functions contracts call, read the Host Function ABI spec.
5.1 Design Principles
The toolchain is built around four principles, each chosen deliberately.
Author owns the build; otigen verifies
By default otigen does not compile WASM. The author runs their language's native build command (cargo build --target wasm32-unknown-unknown --release, npm run build, tinygo build -target=wasi -o build/contract.wasm ., make) themselves. They get the full diagnostics, the full IDE integration, the full test workflow their language ecosystem provides.
otigen build then verifies the result: confirms the .wasm file exists at the path declared in otigen.toml, validates the WASM module structure, cross-checks that the module imports only allowed host functions and exports every function declared in [functions], and generates the deploy bundle. If anything is missing or wrong, otigen says so; if everything checks out, it prints "ready to deploy."
This keeps the toolchain minimal (no per-language compiler invocation logic to maintain) and respects the author's native toolchain.
For the common iterate-on-a-contract case there is also otigen build --compile: an opt-in flag that runs the per-language default build command first (the same invocation the templates document + init's "next:" hint prints), then proceeds with the same verify + package pipeline. Both paths produce byte-identical bundles when the inputs are equivalent — --compile is a UX convenience, not a different build. Authors with custom build flags continue to compile manually and call otigen build (no flag) afterwards; that verify-only path stays supported forever.
Zero extra code in the author's project
A contract project contains only the author's contract logic and an otigen.toml. No bundler files, no glue code, no manifest-handling boilerplate. The author writes what their language requires (a Cargo.toml for Rust, package.json for AssemblyScript, go.mod for Go, Makefile for C/C++) and the contract source itself.
State access and host-function calls go through whatever helper pattern the author or community provides for their language. otigen doesn't ship those helpers, doesn't generate them, doesn't depend on them. It only requires that the resulting .wasm imports the Host Function ABI correctly.
Two test layers, one toolchain
Pyde splits contract testing by layer. Language-native test frameworks (cargo test, npm test, go test, the author's C test harness) cover pure helpers — math, parsing, formatting — at the function-internals layer. The toolchain doesn't wrap them; authors keep their language's standard test workflow.
otigen test covers the layer above: contract behaviour — does transfer decrement the right balance, emit the right event, revert on the right input. It runs the compiled .wasm inside a wasmtime sandbox with mock implementations of every pyde::* host function declared in the Host Function ABI, driven by a TOML test spec (named accounts, named storage slots, time / wave / chain cheats, multi-call sequences, named event matching, named-or-substring revert assertions). The TOML format is language-agnostic — the same .test.toml runs against the contract regardless of source language. Full schema and semantics: OTIGEN_TEST_SPEC.
The split mirrors Foundry's forge test (behaviour) vs Rust's cargo test (unit) — neither subsumes the other, both shipping in one toolchain doesn't compromise the language-agnostic posture.
Attributes and ABI declared in otigen.toml, enforced at runtime
Function attributes (view, payable, reentrant, sponsored, constructor, fallback, receive, entry) and state schema are declared in otigen.toml. otigen build reads them, builds a ContractAbi struct, Borsh-encodes it, and injects it as a WASM custom section named pyde.abi directly into the .wasm artifact the language compiler produced. There is no separate abi.json file at deploy time — the ABI travels with the code as one binary. At runtime, the WASM execution layer extracts the pyde.abi section once, caches the parsed ABI alongside the compiled Module, and applies attribute-driven guards before every call (reentrancy block, view-mode state-write rejection, payable-mode value check, sponsored gas-tank debit, etc.). The WASM module itself does not carry attribute markers — the engine enforces them at the call boundary based on the parsed ABI. Full mechanics: Host Function ABI Spec §3.5–§3.7.
5.2 Subcommand Surface
Every row links to its canonical OTIGEN_BINARY_SPEC section — the spec is authoritative on flag tables, exit codes, and the per-command pipeline. This chapter is the narrative companion.
| Command | Purpose | Spec |
|---|---|---|
otigen init <name> --lang <rust|as|go|c> | Scaffold a new project directory from the language template. Writes otigen.toml + a hello-world contract + language-specific build config (Cargo.toml / package.json + asconfig.json / go.mod / Makefile). The Rust scaffold uses the macro substrate (#[pyde::entry] + pyde::declare_storage!() + pyde::declare_events!()); non-Rust scaffolds ship the raw extern "C" host-fn pattern. | §3.1 |
otigen new <name> --from <template> | Scaffold by cloning a canonical example bundle. Eight templates ship today (counter, erc20-token, erc721-token, simple-multisig, upgradeable-proxy, merkle-claim-airdrop, vesting, dao-governance) — all on the #[pyde::entry] + declare_storage!() macro substrate, all building clean. Produces a fully-working contract + passing test suite — the fastest path from zero to a green otigen test. --list shows the catalog. | §3.11 |
otigen build | Verify + package. Reads otigen.toml, locates the .wasm at the declared path, validates the WASM module (well-formed, imports allowed only, no wasi:* / env), cross-checks declared [functions] exist as WASM exports, builds the ContractAbi, Borsh-encodes it, injects as the pyde.abi custom section, writes <contract>.bundle/ atomically (via a <name>.bundle.partial/ staging dir; a Ctrl-C SIGINT handler sweeps the partial before exit). By default the author runs their own language build; --compile opts in to running it automatically (cargo / npm run build / tinygo / make). Strict validation (rejection of test-only host fns like pyde::debug_log) is the default; --no-strict is the opt-out escape hatch for local inspection. otigen deploy always runs strict and ignores --no-strict. | §3.2 |
otigen check | Same validation pipeline as otigen build (spec §3.2 steps 1–7), minus the bundle write. Fast pre-commit / IDE / TDD gate. Per-violation diagnostics on stderr; exit 1 on any failure. | §3.13 |
otigen deploy | Sign and submit a deploy transaction. Loads the bundle, re-validates, fetches nonce via pyde_getTransactionCount, builds the canonical Tx envelope with tx_type = Deploy + borsh-encoded DeployData{ name, wasm_bytes, contract_type, init_calldata } in tx.data, FALCON-signs the Poseidon2 tx-hash, submits via pyde_sendRawTransaction, polls the receipt. --dry-run to inspect without submitting; --no-wait to skip the receipt poll. --rpc-url <URL> + --chain-id <N> give a one-shot override of [network.<name>] (mandatory pair — raw URL has no chain id, signing against chain_id = 0 silently bricks the FALCON sig). | §3.3 |
otigen upgrade <target> | Engine-gated in v1. The CLI builds the signed tx but refuses to submit (EngineNotReady) because the chain has no TxType::Lifecycle handler yet. v1 pattern: proxy + delegate_call. --i-know-engine-rejects bypasses the gate for stub-engine testing. Mandatory --rpc-url + --chain-id pair applies when overriding. | §3.4 |
otigen pause / unpause / kill | Engine-gated in v1 — same EngineNotReady refusal + --i-know-engine-rejects bypass as upgrade. v1 pattern: author-declared paused: bool / killed: bool in [state], gated in entry-function bodies. kill --yes skips the retype-the-target confirmation; mandatory --rpc-url + --chain-id pair applies when overriding. | §3.5 |
otigen call <target> <fn> [args...] | Sign and submit a contract call (TxType::Standard with data = borsh(CallPayload { function, calldata })). Routes through the chain's WasmExecutor::execute_call for entry-attributed functions; view functions skip submission and go through pyde_call (free, no tx, no gas) when otigen recognises the view attribute from a local otigen.toml. Positional args are typed per [functions.X].inputs — otigen call <addr> transfer devnet-1 100 Just Works; address values resolve wallet names from the local keystore; JSON array syntax [1,2,3] carries vec(T); JSON5 struct + variant-name forms carry [types.<Name>] shapes ({maker:0xaa…,id:1}, Pending). --args <hex> is the escape hatch for raw pre-encoded calldata; view returns auto-decode per [functions.X].outputs (--raw keeps the hex); --value <decimal> attaches a native-token transfer alongside the call. | §3.X |
otigen inspect <target> | Read deployed contract state via the rpc client. Default mode surfaces address, account type, balance, nonce, code hash, code size, state root, and (when the wasm carries a pyde.abi custom section) the full ABI summary: version, function count, constructor / fallback / receive bindings, state schema hash, per-function selector + attribute labels. --state-field <name> reads a substrate-typed scalar field — derives the slot Poseidon2(self_address || field_name) (the chain's sstore_scalar convention), pulls the bytes, and decodes per the type token in [state].schema; renders contract / field / slot / raw / decoded value. --field <name> reads a legacy raw-storage slot via Poseidon2(name) — used by contracts that call sstore / sload directly; mutually exclusive with --state-field. --rpc-url <URL> one-shot override + --at-wave <id> for archive nodes. ⏳ Owner / version history land when the RPC catalog grows the corresponding endpoints. | §3.6 |
otigen verify <target> | Reproducibility check: compares the local bundle's contract.wasm against the chain-stored bytes from pyde_getContractCode. Exit 0 on match, 1 on mismatch with blake3 hashes + size delta + first-diff offset. Two clean local builds of the canonical hello-rust produce byte-identical contract.wasm + abi.json (modulo manifest.build_timestamp) — the make reproducibility gate locks the invariant. | §3.9 |
otigen validator <subcmd> | Read-only validator-introspection over pyde_getValidator + pyde_getOperatorValidators. show <addr> returns one validator's full chain-side record (operator + pubkey + stake + status + jail / unbond timeline + last-claimed reward checkpoint + uptime bps); exits non-zero with NotAValidator for unregistered addresses so shell scripts can branch on exit code. by-operator <addr> lists every validator an operator runs. --json emits the same data as one NDJSON event per invocation. Registration / stake / unbond / unjail / key-rotation flows live on the pyde stake CLI (engine binary). | §3.14 |
otigen wallet | FALCON-512 keystore management. Subcommands: new <name>, list, show <name>, import <name> [--from-file <path> | --from-devnet], delete <name> [--yes], password <name>, export <name> [--out <path>], sign <name> --message <msg>, verify [name] --message <msg> --signature <hex>. import --from-devnet re-derives the 10 deterministic prefunded otigen devnet accounts locally (no network call). ⏳ Only the chain-side rotate (KeyRotationTx) is deferred — it needs the chain to accept that tx variant. | §3.7 |
otigen test | Run contract behaviour tests declared in tests/*.test.toml. Executes through pyde-engine-wasm-exec::WasmExecutor by default — same code path mainnet uses — so authors get every pyde::* host fn at chain fidelity. --no-engine falls back to the legacy in-process mock surface for parachain contracts (parachain runtime ships in engine v2) and runner-side bisection. --no-compile skips the per-language compile step. Named-account + named-slot + cheatcode model, multi-call sequences with per-call and final-state assertions, typed-arg marshalling (address / uint128 / int128 / bytes32 / bytes / primitive ints), FALCON DSL (@pubkey:NAME / @sig:NAME:args.IDX), pyde::debug_log test-only host fn, schema-aware encoding (incl. struct(<Name>) via pyde::declare_storage!()), --watch for Foundry parity, --json NDJSON event stream, standard -v/-vv clap verbosity. | §3.10 + OTIGEN_TEST_SPEC |
otigen console | Interactive REPL against a Pyde node. Shipping surface: help, balance <addr>, nonce <addr>, call <addr> <fn> [hex] (view, free), tx <addr> <fn> [hex] [--value <decimal>] (sign + submit + receipt poll), state <addr> <field> (substrate-typed scalar read; same Blake3(self_address || field_name) derivation + [state].schema decoder inspect --state-field uses), exit / quit. Session-scoped --network / --from bind once at startup; wallet unlock is lazy (views never prompt, first tx asks for password once). Line-edited via rustyline with persisted history at ~/.otigen_console_history. | §3.8 |
otigen devnet | One-command local devnet — the chain runtime is embedded in the otigen binary; there is no separate pyde download or process to fork. Spins up the in-process engine, pre-funds 10 deterministic accounts, exposes JSON-RPC on 127.0.0.1:9933 (plus /ws for subscriptions). Headliner is --fork <FILE_OR_URL>: accepts either a local borsh snapshot file (produced by the engine's Snapshotter::build) or an HTTP(S) URL pointing at a running validator's snapshot RPC. Flags: --rpc-listen, --prefund-count, --prefund-amount, --chain-id, --tick-ms. On Ctrl-C, all state is wiped. | §3.12 |
There is no otigen compile. Authors use their language's native compiler (cargo build --target wasm32-unknown-unknown --release, asc, tinygo build -target=wasi, clang --target=wasm32). The --compile flag on otigen build is an opt-in convenience that invokes the language's default command — not a separate compile subcommand.
5.3 The otigen.toml Schema
A single TOML file declares everything otigen needs to know about the project. The full schema with field-by-field validation rules is documented in OTIGEN_BINARY_SPEC.md §4; the shape below is the canonical reference.
[contract]
name = "my-token" # required; lowercase + hyphens (ENS-style)
version = "1.0.0" # required; semver
description = "Example token" # optional
type = "contract" # "contract" (default) or "parachain"
[contract.lang]
language = "rust" # required; rust | as | go | c
output = "target/wasm32-unknown-unknown/release/my_token.wasm"
# required; path the author's compiler emits
[contract.lang.toolchain]
rust_channel = "stable" # rust only — informational, surfaced in manifest.json
# asc_version, tinygo_version, clang_version for the other languages
[deploy]
gas_limit = 10_000_000 # default per-deploy gas budget
gas_price = "auto" # "auto" = use current base_fee; or fixed quanta
owner_deposit = 1000 # PYDE locked at deploy time (parachain only)
[wallet]
default_keystore = "~/.pyde/keystore.json" # optional; --keystore overrides
default_account = "deployer" # optional; --from overrides
[network.default]
name = "testnet" # selects one of the named [network.X] entries
[network.mainnet]
rpc_url = "https://rpc.pyde.network"
chain_id = 1
explorer_url = "https://explorer.pyde.network"
[network.testnet]
rpc_url = "https://rpc-testnet.pyde.network"
chain_id = 2
[network.devnet]
rpc_url = "http://localhost:9933"
chain_id = 31337
[state]
# State schema; each entry declares a top-level field name + type.
# Used for ABI emission (state_schema_hash) + explorer decoding.
# Authors still write their own slot derivation in contract code —
# otigen does not generate accessor bindings.
schema = [
{ name = "owner", type = "address" },
{ name = "total_supply", type = "uint128" },
{ name = "balances", type = "mapping(address -> uint128)" },
]
[functions.transfer]
attributes = ["entry", "payable"]
inputs = ["address", "uint128"]
outputs = ["bool"]
access_list = [ # optional; prefetch hint for cache warm-up
"balances[caller()]",
"balances[args.0]",
]
[functions.balance_of]
attributes = ["entry", "view"]
inputs = ["address"]
outputs = ["uint128"]
[functions.init]
attributes = ["constructor"] # callable only at deploy time
inputs = ["uint128"]
# ─────────────────────────────────────────────────────────────────
# Custom types — referenced by bare name in [functions.X].inputs/outputs,
# and via struct(...) / vec(...) wrappers in [state].schema.
# ─────────────────────────────────────────────────────────────────
[types.Order]
fields = [
{ name = "id", type = "uint64" },
{ name = "maker", type = "address" },
{ name = "amount", type = "uint128" },
{ name = "paid", type = "bool" },
]
[types.Status]
variants = [
{ name = "Pending" },
{ name = "Active" },
{ name = "Cancelled" },
]
[events.Transfer]
signature = "Transfer(address,address,uint128)"
fields = [
{ name = "from", type = "address", indexed = true },
{ name = "to", type = "address", indexed = true },
{ name = "amount", type = "uint128" },
]
Schema notes
[contract] — identity + version + type (contract or parachain). name is the ENS-style on-chain name (globally unique; see Ch 11 §11.2). The address is derived from the FALCON pubkey at deploy time, not from name; the registry binds name → address.
[contract.lang] — declares which language the author compiled with and where their compiler emits the .wasm. language ∈ {rust, as, go, c}. output is the path otigen build reads. Optional [contract.lang.toolchain] pins specific toolchain versions (surfaced in manifest.json for reproducible-build verification).
[deploy] — defaults for otigen deploy. gas_limit caps the deploy tx's gas. gas_price = "auto" uses the current chain base fee; a fixed integer overrides. owner_deposit is only meaningful for parachain deploys.
[wallet] — points at the default keystore + the default account. Both fields are optional; the global --keystore <path> and per-command --from <name> flags override.
[network.*] — [network.default.name] selects which other [network.<name>] table the toolchain talks to. Each named entry carries rpc_url, chain_id, and an optional explorer_url. The global --network <name> flag overrides at the command line.
[state] — the schema of the contract's storage. Used by otigen build to compute state_schema_hash (which the chain compares against on every state read for type-safety enforcement) and emitted in abi.json for explorers. The author's contract code still derives the storage slots itself — Pyde does not ship per-language storage bindings.
[functions.<name>] — every callable function the runtime should dispatch to. attributes is the safety + dispatch attribute set documented in §5.6. otigen build cross-checks every [functions.X] has a matching WASM export named X and rejects exports that aren't declared. Optional access_list declares the storage slots the function touches; accurate lists optimize cache prefetch performance in the uniform Block-STM scheduler (declaring nothing still works — the chain just runs with a colder cache).
[types.<Name>] — author-declared custom types. Two shapes: a struct declares fields = [{ name, type }, ...]; an enum declares variants = [{ name = "X" }, ...] (v1 is unit-only — no data-carrying variants). Functions reference custom types by bare name in [functions.X].inputs / outputs (e.g. "Order"); storage references them via the struct(<Name>) wrapper in [state].schema (e.g. { name = "current_order", type = "struct(Order)" }), and vec(<Name>) similarly wraps for arrays. Rust contract code needs #[derive(BorshSerialize, BorshDeserialize)] on every custom type — the macro substrate's typed storage + entry-arg decoders depend on it.
[events.<name>] — emitted-event declarations. signature is the canonical string the chain hashes (Blake3) to derive the topic-0 value. Indexed fields are searchable via pyde_getLogs; non-indexed fields are Borsh-encoded into data.
[parachain] (parachain only) — consensus preset, validator constraints, slashing preset. Detailed in Chapter 13.
5.4 Per-Language Workflow
Each language has its own template (scaffolded by otigen init) and its own native build command. The author runs the build; then otigen build verifies + packages.
Rust
otigen init my-contract --lang rust
cd my-contract
# Edit src/lib.rs with contract logic; declare entries + state +
# events in otigen.toml.
# Author runs their own build:
cargo build --release --target wasm32-unknown-unknown
# otigen verifies and packages:
otigen build
otigen deploy --network devnet
Scaffolded project tree:
my-contract/
├── otigen.toml # contract identity, network, [functions.*], [state], [events.*]
├── Cargo.toml # cdylib + release profile tuned for WASM size;
│ # depends on `pyde-host` (the canonical Rust SDK)
├── src/
│ └── lib.rs # #![no_std] template with the macro substrate:
│ # `pyde::declare_storage!()` emits typed storage accessors
│ # from the [state] schema, `pyde::declare_events!()` emits
│ # typed event structs from [events.*], `#[pyde::entry]`
│ # wraps each user fn with the () -> () ABI shim. Authors
│ # write idiomatic Rust against typed args + return values;
│ # no hand-written `extern "C"` blocks or `*const u8`
│ # buffer staging.
└── .gitignore
otigen build does:
- Read
otigen.toml; validate schema (§5.3) + attribute combinations. - Locate the
.wasmat[contract.lang.output](target/wasm32-unknown-unknown/release/<crate>.wasm). - Validate the WASM module (parses cleanly via
wasmparser, every import declares modulepyde, every imported function is on the HOST_FN_ABI_SPEC allowlist, every[functions.X]has a matching export, only deterministic WASM features used). - Run the static call-graph view check: any
view-attributed function whose transitive call graph reaches a state-mutating host function is rejected. - Build the
ContractAbifromotigen.toml, Borsh-encode it, inject as thepyde.abicustom section viawasm-encoder. - Write
<out>/<contract_name>.bundle/containingcontract.wasm(withpyde.abiembedded),otigen.toml(verbatim),abi.json(human-readable mirror),manifest.json(hashes, build timestamp, otigen version, target chain_id).
AssemblyScript
otigen init my-contract --lang as
cd my-contract
# Edit assembly/index.ts; declare entries + state in otigen.toml.
npm install && npm run build # delegates to: asc assembly/index.ts --config asconfig.json --target release
otigen build # verify + package
otigen deploy --network testnet
The scaffold pins runtime: "minimal" in asconfig.json so the resulting WASM imports nothing outside pyde — anything else would fail the chain's import allowlist.
Go (TinyGo)
otigen init my-contract --lang go
cd my-contract
# Edit main.go; declare entries + state in otigen.toml.
tinygo build -target=wasi -o build/contract.wasm .
otigen build # verify + package
otigen deploy --network testnet
The scaffold uses //go:wasmexport ping to mark the entry point and documents the //go:wasmimport pyde caller pattern (commented out) for host-fn imports. TinyGo requires a main(); the chain dispatcher never calls it.
C / C++
otigen init my-contract --lang c
cd my-contract
# Edit contract.c; declare entries + state in otigen.toml.
make # delegates to: clang --target=wasm32 -nostdlib -Wl,--no-entry ...
otigen build # verify + package
otigen deploy --network testnet
The scaffold's Makefile pins -nostdlib so libc never leaks into the resulting WASM (which would fail the allowlist). Host-fn imports go through __attribute__((import_module("pyde"), import_name(<fn>))); the scaffold ships one commented-out example. Exports use __attribute__((export_name(<fn>))).
Why this split
Authors keep their full language toolchain (build errors, IDE integration, dependency management, test runners, fuzzers, profilers — everything). The chain-specific concerns (ABI generation, deploy packaging, on-chain lifecycle) are owned by otigen. The interface between them is the .wasm file + the otigen.toml schema; both are inspectable, neither is generated by the other.
5.5 Build Verification + Packaging
otigen build is purely a validator + packager. It runs in this order:
1. Load otigen.toml; validate schema (§5.3) + attribute combinations per
HOST_FN_ABI_SPEC §3.5.1.
2. Locate the .wasm at the path declared in [contract.lang.output];
reject (exit 2) if the file doesn't exist.
3. Parse the .wasm via wasmparser; reject if the binary is malformed.
4. Walk the WASM import table; reject any import whose module is not
"pyde" or whose function name is not on the HOST_FN_ABI_SPEC
allowlist (and, for non-parachain contract types, reject any
parachain-only host functions).
5. Walk the WASM export table; cross-check every [functions.X] has a
matching export named X, and reject any export that isn't declared.
6. Validate the WASM feature set is in the deterministic subset
(no threads, no SIMD, no reference types, etc.).
7. Run the static call-graph view check: for each `view`-attributed
function, walk its transitive call graph. Reject if any reachable
function imports a state-mutating host call (sstore, sdelete,
transfer, emit_event, parachain_storage_write, etc.).
8. Build the ContractAbi from [functions.*] + [events.*] + [state]
(computing 4-byte selectors as blake3(fn_name)[..4], topic
signature hashes, state schema hash).
9. Borsh-encode the ContractAbi.
10. Inject the encoded ABI into the .wasm as a custom section named
`pyde.abi`, using the `wasm-encoder` crate. The code section is
untouched; reproducible builds still verify byte-identical.
11. Write the bundle to <out>/<contract_name>.bundle/:
- contract.wasm (.wasm with pyde.abi custom section)
- otigen.toml (verbatim copy of the source config)
- abi.json (human-readable ABI mirror)
- manifest.json (blake3 hashes, build timestamp, otigen
version, language toolchain pins,
target chain_id)
12. Print "✓ built <name> → <bundle_path>" with the wasm + abi sizes
and blake3 prefixes (16 hex chars) per artifact.
Exit codes: 0 on success, 1 on validation failure (with a structured error listing every violation), 2 if the .wasm was not found at the expected path. No partial bundles are ever written — the bundle dir is created last, after every validation has passed.
How Rust authors do state access (macro substrate)
The Rust scaffold ships a thin SDK — pyde-host + the #[pyde::entry] macro + pyde::declare_storage!() + pyde::declare_events!() — that hides the WASM ABI entirely. Authors declare a typed state schema once and call generated module-path functions; the macros emit the void-void entry shim (HOST_FN_ABI §3.5.2), unpack borsh calldata into typed arguments, derive Poseidon2(self_address ‖ field [‖ keys]) slots, and call the chain's sstore_scalar / sload_scalar / sstore_map<N> / sload_map<N> host fns.
#![allow(unused)] fn main() { // src/lib.rs — Rust macro substrate. #![no_std] use pyde::Address; pyde::declare_storage! { [state] total_supply: u128, balances: mapping(Address => u128), } pyde::declare_events! { Transfer { from: Address, to: Address, amount: u128 } } #[pyde::entry] fn transfer(to: Address, amount: u128) { let from = pyde::caller(); let from_bal = storage::balances().get(&from); if from_bal < amount { pyde::revert("transfer: insufficient balance"); } storage::balances().set(&from, from_bal - amount); storage::balances().set(&to, storage::balances().get(&to) + amount); events::Transfer { from, to, amount }.emit(); } }
The matching otigen.toml declares the same schema (canonical form or Solidity-style sugar):
[state.fields]
total_supply = "uint128"
balances = "mapping(address => uint128)"
[functions.transfer]
attributes = ["entry"]
inputs = ["address", "uint128"]
The #[pyde::entry] macro generates the void-void shim that reads the borsh-encoded calldata via pyde::calldata_size + pyde::calldata_copy, decodes each declared input, calls the typed transfer body, and (if the function returns a value) writes the encoded bytes via pyde::return. There is no hand-rolled FFI; otigen build's spec-entry check (HOST_FN_ABI §3.5.2) passes automatically.
Non-Rust languages
The other three languages (TinyGo, AssemblyScript, C) don't ship a Pyde-supplied SDK. Authors declare a void-void exported entry, read calldata via pyde::calldata_* host fns, and call host functions directly through the language's FFI mechanism (//go:wasmimport, @external, __attribute__((import_module))). The canonical reference patterns live in pyde-net/otigen/examples/counter-{go,as,c}/. The WASM_AUTHOR_GUIDE companion doc walks the per-language details.
5.6 Safety Attributes via otigen.toml
Otigen the language had a set of compiler attributes that made common safety properties default and explicit. Every one of those properties carries forward unchanged in the WASM era. Authors declare them in otigen.toml [functions.<name>] attributes = [...]; otigen build includes them in the generated ABI; the runtime enforces them by reading the ABI before invocation and applying the appropriate guards.
The mechanism changed (config-declared metadata enforced at the call boundary instead of compiler-extracted markers in bytecode), but the safety guarantees are identical to the Otigen-language era.
Reentrancy is still blocked by default
This is the most important property to preserve. Every public function gets an automatically generated reentrancy guard. To opt OUT of the guard — for a function that genuinely needs to allow re-entry — add the #[reentrant] attribute.
If you write nothing, you are protected.
The attribute set
| Attribute | Effect |
|---|---|
view | Read-only function. Runtime rejects any state-modifying host call inside it. View calls are FREE (no gas) — see HOST_FN_ABI_SPEC §7.8. |
payable | Function accepts PYDE attached to the call. Non-payable functions reject any attached amount. |
reentrant | Opts INTO allowing reentrancy. Default for every function is reentrancy-blocked. |
constructor | Initialization-only. Callable exactly once, at deploy time. |
sponsored | Gas charged to the contract's gas_tank rather than the caller's balance. Enables gasless UX. |
fallback | Invoked when the call's function selector matches no declared function. At most one per contract. |
receive | Invoked on bare PYDE transfers (no selector, value > 0). At most one per contract. Must also be payable. |
entry | Marks the function as callable from outside the contract (top-level tx or cross_call). Required for any function not marked with another dispatch attribute (constructor/fallback/receive). Internal helpers omit entry and are not exposed in the public selector table. |
For attribute compatibility rules (which combinations are rejected at build + deploy), see HOST_FN_ABI_SPEC §3.5.1.
How attributes are declared
Attributes are declared in otigen.toml, per function. The author writes plain TOML; the source code is whatever they write in their language. No per-language macro syntax is needed and no source-code parsing is required.
[functions.balance]
attributes = ["entry", "view"]
inputs = ["address"]
outputs = ["uint128"]
[functions.deposit]
attributes = ["entry", "payable"]
inputs = []
[functions.complex_callback]
attributes = ["entry", "reentrant"] # opts INTO reentrancy; default is BLOCKED
inputs = ["bytes"]
[functions.user_signup]
attributes = ["entry", "sponsored"] # gas paid by contract's gas_tank
inputs = ["address"]
[functions.init]
attributes = ["constructor"] # callable only at deploy time
inputs = ["uint128"]
The author writes the corresponding WASM exports in their language as a void-void function (HOST_FN_ABI §3.5.2). In Rust, the #[pyde::entry] fn balance(owner: Address) -> u128 macro emits the void-void shim. In AssemblyScript, export function balance(): void. In Go (TinyGo), //go:wasmexport balance on a func balance(). In C, __attribute__((export_name("balance"))) void balance(void). Standard WASM-export idioms for each language; the void-void contract is non-negotiable — otigen build's entry-shape validator rejects any non-void-void export declared in [functions.<name>].
What the build tool does with attributes
otigen build validates them (e.g., a function cannot be both view and payable) and writes them into the generated ABI:
{
"functions": [
{
"name": "transfer",
"selector": "0xa9059cbb",
"attributes": ["entry"],
"inputs": [...],
"outputs": [...]
},
{
"name": "balance",
"selector": "0x70a08231",
"attributes": ["entry", "view"],
"inputs": [...],
"outputs": [...]
},
{
"name": "user_signup",
"selector": "0x...",
"attributes": ["entry", "sponsored"],
"inputs": [...],
"outputs": [...]
}
]
}
How the runtime enforces them
The WASM execution layer reads the function's attribute set from the deployed ABI before invocation and applies the appropriate behavior:
| Attribute | Runtime enforcement |
|---|---|
view | Host functions sstore, sdelete, transfer, emit_event trap if called inside a view function. |
payable | If tx.value > 0 and target function is not payable, transaction reverts at dispatch. No state change. |
reentrant | Runtime skips the reentrancy guard for this function. ALL OTHER functions get the guard. |
Not reentrant (default) | On entry, the runtime sets a per-contract reentrancy flag. Any host call that re-enters this contract checks the flag; if set, traps with ReentrancyViolation. On exit, flag is cleared. |
constructor | Callable only by the deploy transaction. Subsequent calls trap. |
sponsored | At dispatch time, the engine debits gas from the contract's gas_tank instead of the caller's balance. If the gas tank is empty, transaction reverts. |
This is identical behavior to Otigen the language. The change is implementation venue: attributes now ride on the ABI declared in otigen.toml rather than on compiler-extracted markers in bytecode. The safety guarantees are the same. The author's per-function declaration moves from source-code annotation to a config file. Both equally explicit; the config form keeps otigen decoupled from per-language source parsing.
Other Otigen design choices preserved
Beyond function attributes, several broader Otigen design choices carry forward as runtime properties of the engine:
| Otigen design choice | How it's preserved in the WASM era |
|---|---|
| Reentrancy off by default | Runtime reentrancy guard for every function not marked reentrant. |
| Checked arithmetic by default | Per-language SDK helper patterns; wrapping ops require explicit opt-in (e.g., Rust's wrapping_add is explicitly named). |
| Typed storage | otigen.toml [state] schema declares types; ABI includes the schema so the runtime + explorers know what each slot is. Authors implement type-safe access in their own code. |
No tx.origin | Host function ABI exposes caller() (direct caller) but no origin(). The Solidity-style phishing footgun is absent. |
| Compile-time access lists | Build tool emits a static access list per function from the declared state schema; these serve as prefetch hints to warm the cache, improving performance but never affecting Block-STM correctness. |
| 4-byte function selectors | Build tool emits selector = first 4 bytes of Hash(function_signature) in the ABI. |
| Sponsored / gasless transactions | #[sponsored] attribute + gas_tank per contract account, exactly as designed in the Otigen era. |
| Reserved-storage-slot guards | Reentrancy guard uses a reserved slot in the contract's state subtree, never reachable by user-allocated slots. |
The safety floor that Otigen provided is preserved end-to-end. The mechanism is different; the contract author's experience is the same.
5.7 Deploy and Upgrade Flow
Deploy
otigen deploy --network testnet
What happens, per spec §3.3:
otigenresolves the bundle dir (default./artifacts/<name>.bundle/fromotigen.toml's[contract.name], override via--bundle <path>).otigenloads the bundle (manifest.json + otigen.toml + contract.wasm) and re-validates WASM + ABI consistency — defense in depth even though the bundle came fromotigen build.otigenresolves the network from--networkor[network.default.name]and the signer wallet from--fromor[wallet.default_account]. Prompts for the wallet password (no echo).otigenfetches the sender's nonce viapyde_getTransactionCount.otigenbuilds the canonicalTx:Tx { from: sender (32-byte Poseidon2(falcon_pubkey)), to: Address::ZERO, value: 0, data: borsh(DeployData { name, wasm_bytes, contract_type, init_calldata }), gas_limit: from [deploy.gas_limit] (default 10_000_000), nonce: fetched above, signature: filled in next step, fee_payer: Sender, access_list: [], deadline: None, chain_id: from [network.<name>.chain_id], tx_type: Deploy (0x01), }otigencomputes the canonical Poseidon2 tx hash (Ch 11 §"Transaction hash") and FALCON-signs it. The signature is NOT included in the hashed payload.--dry-runmode: print tx hash + wire size and exit 0 without submitting.- Otherwise: Borsh-encode the full Tx and submit via
pyde_sendRawTransaction. Print the server-returned tx hash. - Unless
--no-wait, pollpyde_getTransactionReceipt(60 s timeout, 1 s interval) until included. Report success / reverted / out-of-gas.
Exit codes: 0 on inclusion + success, 1 on validation failure, 2 on RPC / network / inclusion-timeout, 3 on revert, 4 on wallet failure.
Upgrade
otigen upgrade <target> --bundle <new-bundle-dir> # contract path
What happens (contract path):
otigenresolves<target>—0x-prefixed address or registered name (auto-resolved viapyde_resolveName).otigenreads the new wasm from--wasm <file>or<bundle>/contract.wasm.- Same signing pipeline as deploy, but the wire shape is
Tx { tx_type: Standard, to: <target>, data: borsh(LifecyclePayload::Upgrade { new_wasm }) }. The chain decodes the payload, re-runs ABI validation against the new bytes, stores the new code, and bumpscurrent_version.
For parachain upgrades, the chain requires equal-power validator-quorum certs collected separately per PARACHAIN_DESIGN §6.2. The CLI flow for parachain governance (--parachain / --finalize <proposal-id>) is deferred to the parachain rollout post-mainnet.
5.8 Wallet Management
The wallet is built into the otigen binary directly — no separate wallet daemon, no external dependency, no extra install step. The cryptographic primitives (FALCON-512 keypair generation, AES-256-GCM keystore encryption, Argon2id key derivation, in-memory key unlock with zeroize-on-drop) carry forward from the archived wright toolchain; the on-disk format was redesigned for the WASM era to match spec §7.1.
Subcommand surface
otigen wallet new <name>
# Generate a new FALCON-512 keypair. Prompts for a password (twice).
# Adds the encrypted keypair to ~/.pyde/keystore.json under <name>.
otigen wallet import <name> --from-file <path>
otigen wallet import --from-devnet
# Two modes: --from-file restores a previously-exported encrypted backup;
# --from-devnet bulk-imports the 10 deterministic prefunded devnet
# accounts (`Blake3("pyde-devnet-v1/" || i)`) — no network call.
otigen wallet list
# List every account in the keystore (name + address).
otigen wallet show <name>
# Print the account's address + public key. No password needed —
# public material is stored unencrypted.
otigen wallet delete <name> [--yes]
# Remove an account from the keystore. Requires retyping the name
# to confirm unless --yes is passed.
otigen wallet password <name>
# Rotate the account's encryption password. Decrypts with the old
# password, generates a fresh salt + nonce, re-encrypts. The keypair
# itself is unchanged.
otigen wallet export <name> --out <path>
# Emit an encrypted backup blob for migration / cold storage.
otigen wallet sign <name> --message <msg>
# Off-chain FALCON-512 signature over arbitrary bytes (NOT chain txs).
otigen wallet verify [name] --message <msg> --signature <hex>
# Verify a FALCON-512 signature against a message and either a named
# account's pubkey or `--pubkey <hex>` directly.
Override the default keystore location via the global --keystore <path> flag (e.g. otigen --keystore ./test-keys.json wallet list).
Keystore format
Per spec §7.1, a single JSON file at ~/.pyde/keystore.json holds every account. Schema:
{
"version": 1,
"accounts": {
"deployer": {
"address": "0x" + 64 hex chars,
"pubkey": "0x" + hex of FALCON-512 public key (897 bytes → 1794 chars),
"ciphertext": "0x" + hex of AES-256-GCM ciphertext of the FALCON secret key,
"salt": "0x" + 32 hex chars (16-byte Argon2id salt),
"nonce": "0x" + 24 hex chars (12-byte AES-GCM nonce),
"kdf": {
"name": "argon2id",
"memory_kb": 65536, // 64 MiB
"iterations": 3,
"parallelism": 4
}
},
"deployer-staging": { ... },
"alice": { ... }
}
}
KDF parameters are embedded per-entry so a future tightening of the pinned values still decrypts old entries.
Unix file permissions are set to 0700 on ~/.pyde/ and 0600 on the keystore file. The plaintext secret key is decrypted in memory only when needed for signing and wiped on drop via zeroize::Zeroizing. The Wallet struct's Debug impl is hand-rolled to redact the secret key bytes so accidental unwrap_err() on a Result<Wallet, _> cannot dump key material into a panic message.
Signing flow
When otigen deploy, otigen upgrade, otigen pause, otigen unpause, or otigen kill is invoked:
- Resolve the wallet name from
--from <name>or[wallet.default_account]. - Resolve the keystore path from
--keystore <path>or the default (~/.pyde/keystore.json). - Prompt for the password via
rpassword(no TTY echo). - Derive the AES-256 key from the password + per-account salt via Argon2id.
- Decrypt the FALCON-512 secret key into a
zeroize::Zeroizingwrapper. - Construct the canonical
Tx, compute the Poseidon2 tx hash, FALCON-sign the digest. - Submit the signed
Txviapyde_sendRawTransaction. Zeroize the secret-key buffer on scope exit.
AES-GCM decryption failures all surface as the same Error::DecryptionFailed variant, regardless of cause (wrong password, tampered ciphertext, corrupt nonce). This avoids a timing oracle that would distinguish "you typed the wrong password" from "someone modified your keystore."
Deferred surface
Only one wallet operation from spec §3.7 is deferred:
rotate <name>— submits a chain-sideKeyRotationTxso an existing account can move to a fresh FALCON keypair without changing its address. Distinct frompassword(which only re-encrypts the local keystore entry). Blocked on the engine accepting theKeyRotationTxvariant.
Hardware-wallet bridges and HSM-backed signing (spec §7.4) are post-mainnet; no FALCON-aware hardware wallets exist yet.
5.9 The Console
otigen console is an interactive REPL against a Pyde node — the natural shape for exploration and ad-hoc debugging once a contract is deployed and you want to poke at it without re-typing connection info on every command.
Pair it with pyde devnet for the canonical local loop: one terminal runs the devnet, another runs otigen console against it. Session-scoped --network + --from bind once at startup so every command in the session reuses the same RPC URL + sender; wallet unlock is lazy (view-only commands never prompt, first tx asks for the password once).
Shipping commands
| Command | What it does |
|---|---|
help | Lists the full command catalog with one-line descriptions. |
balance <addr> | Calls pyde_getBalance; renders raw quanta + pretty-printed PYDE. |
nonce <addr> | Calls pyde_getTransactionCount; shows the next-acceptable nonce. |
call <addr> <fn> [hex] | View-mode pyde_call — free, no nonce, no receipt. Returns the contract's return_data bytes; --json mode surfaces it on the call_included event. |
tx <addr> <fn> [hex] [--value <decimal>] | Builds a Standard tx, FALCON-signs it, submits via pyde_sendRawTransaction, polls the receipt. |
state <addr> <field> | Reads a substrate-typed scalar storage field — derives the slot Poseidon2(self_address ‖ field_name) (the chain's sstore_scalar convention), pulls the bytes, decodes per the type token in [state].schema. Map fields print a clear "scalar-only MVP scope" message rather than truncating. |
exit / quit | Leaves the REPL with status 0. |
Address arguments accept either 0x-hex or a registered name (when pyde_resolveName lands; today only hex resolves).
How state compares to inspect --state-field
Both use the same Poseidon2 slot derivation and the same primitive-type decoder. The difference is the workflow:
inspect --state-fieldis the scriptable path — one-shot,--json-able, designed for CI / deploy scripts that want to assert a single value after a deploy.console stateis the interactive path — drop into a REPL, poke at multiple fields across multiple contracts without re-typing the RPC URL or sender, exit when you're done.
Implementation lives in a single otigen-cli::state_decode module both surfaces consume, so the decoder vocabulary stays in lockstep.
History and editing
Line-edited via rustyline with persisted history at ~/.otigen_console_history. Up-arrow recalls prior commands across sessions.
Deferred surface
Two REPL commands are reserved by spec but blocked on engine work:
events <addr> [--from N] [--to N]— historical event-log query. Needspyde_getLogs(filtered + cursor-paginated). Ask filed.subscribe <addr>— live event tail. Needs bothpyde_getLogsand a websocket transport on the devnet.
Both will land in a follow-up once the chain-side methods ship.
5.10 What the Toolchain Does NOT Do
Deliberately omitted:
- Language-native unit-test runner — use
cargo test/npm test/go test/ the author's C test harness for pure-helper unit tests.otigen testcovers contract behaviour (state changes, events, reverts), not language-internal function testing. The two layers are complementary, not overlapping (§5.1, OTIGEN_TEST_SPEC). - Linter / formatter — use the language's native tooling (
rustfmt,prettier,gofmt,clang-format). - IDE integration — uses the language's standard LSP; no Otigen-specific IDE extension required.
- Documentation generator — use the language's standard (
rustdoc,typedoc, etc.). - Dependency manager — use the language's standard (
cargo,npm,go mod, etc.). - Custom syntax — there is none; the contract is whatever the language allows.
The toolchain wraps deployment-specific concerns + the chain-aware behaviour-test layer. Everything else stays in the language ecosystems the authors already know.
5.11 Performance
The whole toolchain side of the pipeline — parse otigen.toml, validate every cross-cutting rule, walk the compiled .wasm for imports + exports + deterministic-feature compliance, build the canonical ContractAbi, Borsh-encode it, inject the pyde.abi custom section — measures in single-digit microseconds end-to-end. Validation work is essentially free against the file-system overhead of reading the .wasm and writing the four bundle files; a typical otigen build invocation is dominated by I/O (~1–5 ms in practice), not by validator CPU.
Reference numbers on an Apple M-series dev machine (arm64, macOS 15), measured by the criterion benches committed under crates/<crate>/benches/baseline/*.json in the pyde-net/otigen repo. Reproduce with cargo bench -p otigen-toml --bench parse_validate and cargo bench -p otigen-abi --bench abi_pipeline.
| Operation | Median |
|---|---|
selector_of (Blake3 prefix, function-name → 4-byte selector) | 50 ns |
Attributes::from_attributes (3-attribute set) | 1 ns |
from_project_config (build canonical ContractAbi from parsed TOML) | 449 ns |
Borsh encode ContractAbi (3-function contract) | 39 ns |
Borsh decode ContractAbi | 156 ns |
pyde.abi custom-section inject (3-fn realistic WASM) | 494 ns |
pyde.abi custom-section extract | 154 ns |
| WASM import validator (3 imports against the host-fn allowlist) | 196 ns |
WASM export validator (cross-reference vs ContractAbi) | 343 ns |
| WASM deterministic-feature validator (full function-body opcode pass) | 2.3 µs |
otigen.toml parse (canonical spec example, ~50 lines) | 23 µs |
otigen.toml cross-cutting validation pass | 278 ns |
otigen.toml parse + validate (stress: 100 functions + 50 events + 30 state fields) | 488 µs |
| Full in-memory toolchain pipeline (parse → validate → build → encode → inject) | 14.5 µs |
These numbers are tracked from commit pyde-net/otigen#6 forward. Future regressions surface on PRs that run cargo bench --baseline=v1.
The benches are intentionally tight scope — they measure the toolchain-side work, not the chain-side deploy validator (which redoes every check at deploy time per HOST_FN_ABI_SPEC.md §3.7 layer 3) and not the wasmtime AOT compilation step (which happens on the chain at first invocation of a deployed contract, not at otigen build time).
5.12 Contract Behaviour Tests (otigen test)
The toolchain ships a TOML-driven contract test runner. Authors write tests/<name>.test.toml, run otigen test, and get pass / fail per scenario — the same workflow Foundry users know from forge test, adapted to Pyde's host-function surface.
The full schema, name-resolution rules, cheatcode catalogue, mock host-function behaviour, and limitations are documented in OTIGEN_TEST_SPEC.md. The short overview:
What gets tested
- State changes — assert balances / counters / mappings after a call sequence.
- Return values — assert a function returned the expected scalar.
- Events — assert
Transfer(from, to, amount)(or any declared event) emitted with the right indexed + non-indexed fields. - Reverts — assert a call traps with a reason substring (
"InsufficientBalance"). - Multi-step scenarios — assert "alice transfers to bob, then bob transfers to carol; final state is …" across multiple calls in one test.
- Time / wave / chain conditions — cheatcode
now,wave_id,chain_idper test.
What it looks like
# tests/contract.test.toml
[accounts]
alice = { balance = "0x100" }
bob = {}
[[tests]]
name = "transfer_moves_balance"
[tests.setup]
storage.balances.alice = "100"
storage.balances.bob = "0"
[[tests.calls]]
function = "transfer"
from = "alice"
args = ["bob", "10"]
expect.return_value = "1"
expect.events = [
{ name = "Transfer", from = "alice", to = "bob", amount = "10" },
]
[tests.expect]
storage.balances.alice = "90"
storage.balances.bob = "10"
Named accounts resolve to 32-byte addresses via the chain's name registry; storage field names resolve to Poseidon2 slots (Poseidon2(self_address ‖ field_name ‖ keys...)) per the contract's [state] schema. Authors never type slot hashes by hand.
How it runs
otigen test discovers tests/*.test.toml, spins up a wasmtime engine, loads the contract's .wasm from ./artifacts/<name>.bundle/contract.wasm, and executes each test against a fresh TestEnv that mocks every pyde::* host function:
- Real
hash_poseidon2/hash_blake3/falcon_verify(viapyde-crypto) so author-side slot derivation + signature verification match the runner exactly. - Mock storage (
sload/sstore/sdelete), account (balance/transfer), context (caller/self_address/wave_id/wave_timestamp/chain_id), tx (tx_value), events (emit_event), halt (revert), cross-call (cross_call/delegate_call), and parachain §8 host fns (parachain_storage_{read,write,delete}/parachain_id/parachain_version/parachain_emit_event) against an in-memory state map. - Test-only
pyde::debug_logprintf-style host fn captured in the call's debug log buffer; rejected byotigen build(strict is the default) and always rejected byotigen deploy. Useotigen build --no-strictfor local inspection only. - Host fns that trap with
UnsupportedHostFnin v1:origin,tx_hash,tx_gas_remaining,calldata_size,calldata_copy,hash_keccak256,cross_call_static,consume_gas,beacon_get, plus the DKG / threshold-encryption surface. Each either depends on chain-derived state the runner doesn't model, or no canonical example exercises it yet.
What it doesn't do (v1)
- No parallel-execution simulation; calls run sequentially.
- No fuzzing / property tests; example-based only (reserved for a future polish item).
- No multi-tx context — each test starts from fresh state; "deploy in tx1, then call from a different sender in tx2 within one test" isn't expressible (use
otigen devnet+otigen callfor multi-tx flows). - No simulating chain-side validators (mempool, access-list, nonce window) — those run on a real node; pair with
otigen deploy --network devnetfor end-to-end verification.
Every limitation has a future-phase plan in OTIGEN_TEST_SPEC.md §9. The v1 surface is deliberately scoped to what most contract authors need on day one — behaviour, state, events, reverts, gas tracking, cross-contract, FALCON sigs, schema-aware typed args, parachain extension surface — without buying the complexity of fuzz / multi-tx orchestration up front.
When to use what
| You want to test | Use |
|---|---|
| Pure helper functions (math, parsing) | Language-native test runner (cargo test, npm test, go test) |
| Contract behaviour given storage / time / caller | otigen test |
| Cross-contract integration | Devnet (real chain integration) |
| Fuzz / property testing of pure helpers | Language-native fuzzer (proptest, quickcheck) |
| Multi-validator chain behaviour | Devnet + the performance harness (Companion: PERFORMANCE_HARNESS) |
The three layers (unit / behaviour / integration) compose; each catches things the others miss. otigen test is the middle layer that didn't exist before this rev of the toolchain.
5.13 Reading on
- Chapter 3: Execution Layer — the runtime that contracts compile into.
- Chapter 4: State Model — what
sloadandsstoresee. - Chapter 11: Account Model — the ENS-style name registry that the toolchain registers against.
- Chapter 13: Cross-Chain (Parachains) — parachain-specific deploy and upgrade flows.
HOST_FN_ABI_SPEC.md— the locked binary contract between WASM modules and the engine; every imported function the toolchain accepts is in its allowlist.OTIGEN_BINARY_SPEC.md— the canonical specification for this binary. Every subcommand, flag,otigen.tomlschema rule, bundle format, exit code, and validation pass is defined there. If the implementation and the spec disagree, the spec is right and the code is a bug.OTIGEN_TEST_SPEC.md— the canonical specification forotigen test: TOML schema, name resolution, cheatcode catalogue, mock host functions, limitations.
Consensus: Mysticeti DAG
Note: This chapter reflects the post-2026 pivot. The previous HotStuff variant is archived in archive/.
Pyde's consensus is a Mysticeti-style DAG protocol. A committee of 128 validators participates each epoch; every round (~150ms), each member produces exactly one vertex; commits flow continuously at the round rate; finality lands at ~500ms median.
There is no single proposer, no view changes, and no separate prove-then-commit pipeline. Order emerges deterministically from the DAG by every honest validator independently.
1. Why DAG (Why Not HotStuff)
Pyde's previous architecture used a modified pipelined HotStuff with VRF proposer selection. Persistent wedges, head-divergence deadlocks, and view-change cascades motivated a rebuild.
The DAG approach removes the fragile parts:
| Problem in HotStuff | DAG resolution |
|---|---|
| Single proposer bottleneck | No proposer — every member contributes |
| View change protocol complexity | No view changes — eliminated entire failure class |
| Timing-driven slot pipeline | Data-driven rounds advance with quorum, not clock |
| Proposer can censor selectively | 127 honest can include; censorship requires near-unanimous |
| Proposer can extract MEV | No single party reorders; order emerges from DAG |
| Throughput limited by leader bandwidth | Scales with committee size |
| HotStuff bugs cluster in view-change code | DAG doesn't have view-change code |
The same lab/laptop devnet that hit ~4K TPS under pre-pivot HotStuff is the baseline against which DAG performance will be measured. The v1 honest throughput target (to be established by the multi-region performance harness) covers both plaintext and encrypted regimes under production-realistic conditions.
2. Worker / Primary Split (Narwhal Pattern)
Each validator runs:
- Workers (1 or more processes): handle high-volume transaction ingress, build batches, gossip batches peer-to-peer
- Primary (1 process per validator): handles consensus — produces vertices, gathers parents, signs state roots
┌──────────────────────────────────────────────────┐
│ Validator │
│ │
│ ┌───────────────┐ ┌────────────────────────┐ │
│ │ Workers │ │ Primary │ │
│ │ (N parallel) │ │ │ │
│ │ │ │ - One vertex / round │ │
│ │ - Tx ingress │◄──┤ - Tracks local DAG │ │
│ │ - Encryption │ │ - Anchor selection │ │
│ │ (if needed) │ │ - State root signing │ │
│ │ - Batches │ │ - DKG participation │ │
│ │ - Gossip │ └────────────────────────┘ │
│ └───────────────┘ │
└──────────────────────────────────────────────────┘
This separation is load-bearing: it lets data flow at network-rate while consensus messages stay small (~few KB).
3. The Vertex
#![allow(unused)] fn main() { struct Vertex { round: u64, member_id: u32, // committee position batch_refs: Vec<BatchHash>, // batches I have, by hash parent_vertex_refs: Vec<VertexHash>, // ≥85 round-(N-1) hashes state_root_sigs: Vec<StateRootSig>, // attestations on recent commits prev_anchor_attestation: VertexHash, // attests prior anchor decryption_shares: Vec<DecryptionShare>, // piggybacked partials falcon_sig: FalconSig, // sig over the vertex } }
Three categories of references in a vertex:
- batch_refs: point to data (batch blobs in worker storage)
- parent_vertex_refs: point to consensus structure (prior round's vertices)
- state_root_sigs + prev_anchor_attestation: point to consensus output (recent commits)
A vertex is dual-role: header (declaring what data I have) AND attestation (acknowledging prior-round work via parent refs). Parent refs ARE the implicit votes — no separate vote messages.
Vertex Size
Compact-encoded (parent refs as bitmap, hash truncation):
- Minimal: ~830 bytes
- Heavy (50 batches + 5 sigs + 85 partials): ~25 KB
- Hard limit: 64 KB
4. Rounds
A round is a layer in the DAG. The round counter is data-driven, not clock-driven:
A member ticks from round N to N+1 the moment they collect ≥85 valid round-N parent vertices in their local DAG view. Slow members lag behind in their counter; the slowest 43 of 128 don't block anyone (128 − 85 = 43 can lag without holding up the rest).
Round 5: [128 vertices, one per member]
↑↑↑↑↑ each refs ≥85 of layer 4 ↑↑↑↑↑
Round 4: [128 vertices]
↑↑↑↑↑ each refs ≥85 of layer 3 ↑↑↑↑↑
Round 3: [128 vertices]
... etc
Parent rule: parents must be strictly from prior round (round_N - 1). No skip edges in v1. This guarantees acyclicity; violations are slashable.
Round rate: ~5-10 rounds/sec depending on network conditions. Faster than 400ms slots while requiring no clock-based timeouts.
5. Anchor Selection
Each round has a deterministically-selected anchor:
anchor_member_id = Hash(beacon, round, prev_state_root) mod 128
Components:
- beacon: epoch-scoped randomness, published in last wave of prior epoch
- round: current round number
- prev_state_root: state root from N=3 rounds ago (limits anchor predictability to ~450ms)
Properties:
- Deterministic — every honest validator computes the same answer
- Unpredictable — depends on state root that wasn't known until recently
- No single proposer authority — anchor doesn't propose, it's just a starting point for the subdag walk
5b. Round vs Wave — terminology
The distinction matters because the two terms diverge under skips:
- Round = a horizontal layer in the DAG. Every committee member produces exactly one vertex per round. Round numbers always advance (~150ms each), never skip.
- Wave = a successful commit. Wave IDs only increment when an anchor commits. Wave IDs are sparse when rounds skip.
If round 5's anchor (Hash(beacon, 5, prev_state_root) mod 128 = validator 88) is missing:
- Round 5 still happens. The other 127 validators produce their round-5 vertices.
- No wave commits for round 5.
- Round 6 happens; its anchor is a different validator (probably).
- If round 6's anchor succeeds, that wave commits the subdag spanning rounds 5 + 6.
Chain cannot get stuck on one missing anchor. Each round picks a new anchor candidate via the formula, so consecutive failures require consecutive bad luck (or multi-validator outage). The protocol only enters hard-halt territory beyond ~20 consecutive failed anchors (per Chain Halt & Recovery).
Skipped rounds in the chain log
Wave commit records carry both the current anchor_round and the prior_anchor_round. Skipped rounds are implicit from the gap:
WaveCommitRecord(wave_id=6, anchor_round=10, prior_anchor_round=Some(9)) → no skips
WaveCommitRecord(wave_id=7, anchor_round=16, prior_anchor_round=Some(10)) → rounds 11-15 skipped (5 consecutive)
No separate skipped_rounds[] table. The gap IS the record.
5c. Missing-Vertex Handling
When a validator needs a vertex it doesn't have (either the anchor or any vertex in the subdag walk), it fetches the vertex from peers via the dedicated consensus channel.
Validator's local processing:
Walking anchor → parent V_a → V_a's parent V_b ...
Hits a missing vertex V_x referenced by some V in the subdag.
Issues fetch request: get_vertex(V_x_hash) to up-to-8 peers in parallel.
Outcome 1 (typical, ~99.9% of cases):
A peer responds within <500ms with V_x.
Validator verifies V_x's FALCON sig, places it in local DAG.
Continues subdag walk.
Wave commits normally.
Outcome 2 (rare):
No peer responds within the structural timeout (~rounds R+3 materialized).
Validator marks this commit as "cannot complete."
Wave does NOT commit; same handling as anchor-skip.
Vertices stay in DAG; next anchor will retry.
Critical principle: validators NEVER assume "the vertex wasn't gossipped" when missing it. They assume "I haven't received it yet" and fetch. Dropping waves on missing vertices would make the chain trivially censurable.
The fetch protocol lives in the network layer (Chapter 12 + companion/NETWORK_PROTOCOL.md) as a libp2p request-response stream, separate from gossipsub. The fetch is fire-and-retry — ask peer A, if no response in 500ms ask peer B, etc.
Skipped-round recovery walkthrough
5 consecutive skips (rounds 11-15), commit at round 16:
Round: 10 11 12 13 14 15 16
Outcome: WAVE 6 skip skip skip skip skip WAVE 7
(vertices still produced;
accumulate in DAG)
↑
subdag walk goes back
to wave 6's frontier
At round 16's commit (wave 7):
Subdag walk from v_r16_anchor:
- v_r16_anchor's parents = 85+ round-15 vertices
- each round-15 vertex's parents = 85+ round-14 vertices
- ...continue back through rounds 14, 13, 12, 11
- round-11 vertices' parents = 85+ round-10 vertices ← STOP
(these were committed in wave 6)
Subdag = vertices from rounds 11, 12, 13, 14, 15, 16
≈ 6 × 128 = 768 vertices total
Execute all txs in canonical order (round ascending → member_id → list_order)
Compute state_root after applying all changes
Compute events_root (Blake3 Merkle tree over canonical-ordered events) + events_bloom
Sign HardFinalityCert(wave_id=7, state_root, events_root, events_bloom)
Wave 7 record: WaveCommitRecord(wave_id=7, anchor_round=16, prior_anchor_round=Some(10),
state_root=..., events_root=..., events_bloom=...,
events_count=..., tx_count=..., gas_used=...)
The wave commit record carries both state and events summaries; both are threshold-signed in the HardFinalityCert so light clients verify event inclusion identically to state. Full structure + indexing mechanics: Host Function ABI Spec §15.2.
Properties:
- Zero tx loss. Every vertex produced eventually commits.
- Bounded latency cost. Each skip adds ~150-300ms to confirmation time.
- No special "catch up" code. The standard subdag-walk handles it. The BFS just walks more rounds when there's a wider gap.
6. Commit
When the anchor vertex collects sufficient support from later rounds (Mysticeti 3-stage support), a commit fires:
1. Anchor selected (deterministic by formula above)
2. Walk anchor's parent_vertex_refs transitively → collect "subdag"
3. Sort subdag deterministically:
- primary key: round number ascending
- secondary key: member_id
- tertiary key: list order within vertex
4. For each vertex in sorted order, dereference batch_refs
5. For each batch, threshold-decrypt (pipelined ceremony — partials already in flight)
6. wasmtime executes decrypted batches in canonical order
7. State root computed (Blake3 + Poseidon2 dual)
8. ≥85 committee FALCON-sign state root (piggybacked on next-round vertices)
9. ≥85 state-root sigs collected → finality declared
Commit Rate
- ~95% of rounds commit successfully in steady state
- ~5% skip (anchor offline or insufficient support); next round absorbs the data
- Average finality: ~500ms median, ~1s p99
No Skip Penalty
When a round skips, its vertices aren't lost — the next round's commit absorbs them via parent-chain traversal. Slow validators just contribute slightly later.
7. Committee
Size & Selection
- 128 active committee members per epoch, selected from the global validator pool
- Selection: uniform random from all validators with stake ≥
MIN_VALIDATOR_STAKE= 10,000 PYDE (single-tier model; no separate committee vs non-committee stake floor) - Anti-Sybil: operator identity binding, max 3 validators per operator
- Epoch length: ~3 hours wall-clock (commit count varies with network conditions — typically ~21,600 commits at the 500 ms median cadence)
# T-30min in epoch N: beacon_N+1 has just been finalized (see §9).
# Every staked validator now derives the committee for epoch N+1:
eligible = [v for v in all_validators
if v.stake >= MIN_VALIDATOR_STAKE and not v.jailed]
for slot in 0..128:
seed = Hash(beacon_N+1, slot)
member = uniform_random_pick(eligible, seed)
committee[slot] = member
eligible.remove(member) # without replacement
# Committee N+1 immediately begins DKG — see §10 for the boundary timing.
Equal Power Within Committee
All 128 members have equal voting weight, equal vertex production rate, equal anchor probability (uniform over members). Stake influences only:
- (a) eligibility (must meet
MIN_VALIDATOR_STAKE= 10,000 PYDE) - (b) proportion of the stake-weighted reward pool (yield distributes by
stake × uptime)
Activity rewards within the committee are contribution-weighted, not stake-weighted.
Why No Stake-Weighted Voting
- Sybil attack mitigated by operator identity cap (not by stake weight)
- Within-committee equality aligns with classical BFT theory
- Reduces plutocracy pressure
- Simpler protocol math (no stake weights in BFT thresholds)
8. BFT Properties
For n=128 validators:
f = ⌊(n-1)/3⌋ = 42(maximum Byzantine)threshold = 2f+1 = 85(quorum for commit / vertex cert / threshold decrypt)
The number 85 appears throughout the protocol:
- Vertex certification (parent refs in next round)
- Commit support
- Threshold decryption shares
- State root signatures
- DKG share threshold
Consistent across the protocol — avoids attack edges from boundary mismatches.
Safety
Holds under any network conditions assuming at most f = 42 Byzantine members (the BFT tolerance ⌊(n-1)/3⌋ with n = 128). Safety property: no two conflicting commits.
Liveness
Holds under partial synchrony (messages eventually delivered, bounded clock skew).
9. Randomness Beacon
Each epoch's beacon is produced by the previous epoch's committee. The beacon for epoch N+1 must be finalized with enough lead time for committee N+1 to be selected (via VRF on beacon_N+1) and run DKG before the epoch boundary. The architectural target uses a threshold-signature primitive; v1 ships a FALCON-aggregate approximation (called out below).
Target design (threshold-sig — long-term)
1. All 128 committee N members sign known message "epoch_N+1_beacon" with
threshold-share keys (DKG'd at the start of epoch N)
2. ≥85 shares Lagrange-combine into ONE deterministic aggregated signature
3. beacon_N+1 = Hash(aggregated_signature) → 32 bytes
4. Finalized at T-30min (last 30 min of epoch N) — see §10 for why
Properties of the target design:
- Deterministic given any 85 of 128 shares (Lagrange invariance — same aggregated sig regardless of which 85 contribute)
- Unpredictable until ≥85 shares combine (no single party knows it)
- Bias-resistant — shares determined by DKG-derived keys, no individual member can grind by choosing whether to participate; the aggregated output doesn't depend on subset selection
v1 implementation (FALCON-aggregate approximation)
pyde-crypto does not yet ship a post-quantum threshold-signature primitive — post-quantum threshold sigs are research-level (see WHITEPAPER §3 honest-tradeoffs section). v1 ships a FalconBeaconScheme that approximates the target by hash-concatenating individual FALCON sigs:
1. Each member i signs the epoch message with their own individual FALCON
beacon keypair (persisted on disk, separate from consensus FALCON keypair)
2. Shares gossip via pyde/beacon-shares/1
3. Combine: lowest-member-id 85 shares are sorted, concatenated, and
blake3-hashed → beacon_N+1
The v1 approximation deviates from the target design in two ways:
- Subset disagreement is structurally possible because the hash depends on which 85 shares are included, not just that 85 contributed. v1 fixes this by hardcoding a canonical-subset rule (
combineMUST use exactly the lowest-member_id85 shares) so every validator deterministically agrees on the same 85. - Last-signer grinding bias (~1 bit per epoch): a member who signs late sees prior shares and could compute
beacon_if_I_signvsbeacon_if_I_withhold. Bounded by theprev_beaconhash-chain (compounds against the adversary across epochs) and by the ≥85 honest sigs always inside the hash. Full elimination waits for true threshold sigs.
When pyde-crypto ships threshold-FALCON or an equivalent post-quantum threshold-sig primitive, the BeaconScheme trait swaps cleanly to the target design without consensus-side changes.
10. DKG (Distributed Key Generation)
Each epoch transition, the new committee runs DKG to produce a fresh threshold encryption key. The "old committee makes beacon, new committee runs DKG" split keeps key generation truly distributed (only the new committee members contribute their own per-member randomness to the new key) while the old committee handles the bootstrap randomness via the beacon.
Pedersen DKG, multi-round protocol (~30-60s in background):
Round 1: Each member i picks random secret polynomial f_i(x), degree 84
Round 2: Each member broadcasts public commitments to f_i's coefficients
Round 3: Member i sends f_i(j) to each other member j (encrypted point-to-point)
Round 4: Member j verifies received shares against public commitments,
sums valid shares: s_j = Σ f_i(j) = f(j)
where f(x) = Σ f_i(x) is the combined polynomial
Result:
- Each member j holds s_j = f(j) (private share)
- Public key PK derived from public commitments
- SK = f(0) is NEVER computed
- Threshold = 85 of 128
Mathematical foundation: any 85 points on a degree-84 polynomial uniquely determine it (Lagrange interpolation). 84 points don't.
Timing: ~30 min tail window of the prior epoch
DKG runs during the last ~30 minutes of epoch N so committee N+1 has its threshold key ready by the epoch boundary. The full chicken-and-egg sequence:
T-30min (last 30 min of epoch N):
├── beacon_N+1 finalized (see §9 — old committee's combine ceremony)
├── every staked validator runs VRF(validator_sk, beacon_N+1)
├── lowest 128 VRF outputs become committee N+1
├── committee N+1 begins DKG IMMEDIATELY
│ (Pedersen-style rounds + complaint window propagate within minutes;
│ ~30 min leaves comfortable margin for stragglers and retries)
└── committee N continues serving consensus + threshold decryption
EPOCH BOUNDARY (T=3hr):
├── DKG complete → committee N+1 has threshold encryption key
├── committee N hands over instantly
└── threshold decryption continuous — no downtime
Important: beacon production in v1 does NOT depend on DKG — each member signs beacon shares with their individual FALCON beacon keypair (the BeaconKeypair, distinct from the consensus FALCON keypair). DKG is purely for threshold decryption. So:
- Consensus + beacon handover is instant at epoch boundary
- DKG propagation delay only blocks threshold decryption (encrypted txs), never consensus
- If DKG ever fails to complete in the tail window (network partition, complaint storm), encrypted txs queue until DKG finishes — plaintext consensus continues unaffected
Why ~30 min and not less: DKG rounds + complaints + gossip diameter across a 128-member global committee land in the 30-60 second range under good conditions, but the complaint protocol can re-run if any member is contested. 30 min absorbs the worst-case retry path at the 3-hour epoch length.
11. Threshold Decryption Ceremony
Encryption is per-transaction, not per-batch. A batch can contain any mix of plaintext and encrypted transactions. The threshold-decryption ceremony runs per encrypted transaction.
After commit fires, for each encrypted transaction across the canonical-ordered subdag:
Each committee member i (during prior rounds — pipelined):
- For each encrypted tx observed in mempool batches:
partial_i = ApplyShare(s_i, ciphertext_of_tx)
(single elliptic-curve op or polynomial multiplication, ~100μs-1ms)
+ FALCON sig over (partial_i, tx_hash)
- Piggyback the partial(s) on next-round vertex (no separate message channel)
At commit time:
- Subdag walk identifies all encrypted txs in the wave
- Collect their partials from the subdag's vertices (decryption_shares field)
- For each encrypted tx:
- Verify each partial's FALCON sig (~80μs per share)
- Once ≥85 valid partials collected: Lagrange interpolation → plaintext
- Batch the combine work: share-application math vectorizes well on SIMD/GPU
- wasmtime executes decrypted (plus already-plaintext) txs in canonical order
Pipelining
Partials are computed as soon as the encrypted tx enters the mempool DAG, before the commit fires. By commit time, partials are typically 80%+ propagated through vertex gossip. Effective post-commit decryption latency: tens of milliseconds.
Scale via batched share-combine
Headroom analysis, not a v1 claim. v1's honest encrypted-throughput target is to be established by the multi-region performance harness. The math below sizes what it would take to push encrypted throughput well beyond that — useful for understanding the scaling lever, not a v1 promise.
At encrypted throughput well beyond the v1 target:
- Per-tx ceremony: 85 partials × ~80μs verify + ~1ms Lagrange = ~8ms CPU work
- Multiplied across a high transaction rate, that per-tx cost overruns a single core's per-second budget — naive sequential combine isn't feasible. But share-combine vectorizes:
- Group partials by ciphertext, combine in parallel across cores
- GPU acceleration on the share-combine path is the realistic post-v1 scale lever
See WHITEPAPER §11 for honest scaling limits.
12. State Root Attestation
After wasmtime execution, each member computes the state root locally (deterministic from input). Members FALCON-sign the state root with explicit hash inclusion:
#![allow(unused)] fn main() { struct StateRootSig { commit_id: u64, state_root_hash: Hash, // explicit — both Blake3 and Poseidon2 signer_id: u32, falcon_sig: FalconSig, // FALCON over (commit_id || root_hash) } }
Sigs piggyback on next-round vertices. Finality requires:
- ≥85 sigs
- All attesting the same root hash
- All FALCON sigs verify
If sigs attest different roots → fork detected → hard halt (see CHAIN_HALT.md).
13. Failure Detection & Halts
Three types of halts:
| Type | Trigger | Authority |
|---|---|---|
| Soft stall | Network / quorum issues | Emergent |
| Hard halt | Contradictory state roots, equivocation cluster, DAG fork | Protocol-detected automatic |
| Emergency halt | Off-chain bug report, active exploit | Governance multisig (7-of-12) |
See CHAIN_HALT.md for full halt + recovery procedures. Rollback is bounded to 1 epoch (~3 hours) — operational flexibility without arbitrary commit reversibility.
14. Slashing
Equivocation, bad state-root signatures, invalid vertices, bad decryption shares, DKG failure, share withholding, extended downtime — all slashable. See SLASHING.md for the full catalog.
Correlated slashing applies a 2× multiplier when many validators offend simultaneously (punishes coordination, protects isolated failures).
15. Recovery Properties
- Single validator offline: other 127 continue normally. Validator catches up via gossip; loses activity rewards.
- 43+ validators offline (at the BFT quorum boundary, 85 active = 2f+1 with no margin): soft stall; downtime slashing PAUSES (partition-aware); resumes when active count returns to 86+ (one above the quorum minimum).
- Network partition: majority-side continues if quorum maintained; minority stalls.
- State root divergence: hard halt; investigation; rollback within 1 epoch; slashing for wrong-root signers.
The chain self-heals from any subset failure that maintains ≥85 functional validators.
16. Comparison
| Property | HotStuff (pre-pivot) | Mysticeti DAG (current) |
|---|---|---|
| Slot/round timing | 400ms clock | Data-driven (~150ms/round) |
| Proposer model | Single per slot (VRF) | None |
| View changes | Yes (cascade-prone) | None |
| Finality | ~1s+ (chained QCs) | ~500ms (per-round) |
| Throughput ceiling | Leader bandwidth | Committee parallelism |
| Censorship resistance | Proposer-dependent | 127-of-128 can include |
| MEV resistance | Proposer + threshold-enc | Structural (no proposer) |
| Liveness under failure | View-change cascades | Graceful (lag, no halt) |
17. Implementation Status
🔴 Mysticeti DAG implementation: not yet built. Pre-pivot HotStuff archived in archive/.
Implementation strategies:
- Option A: Fork Sui's Mysticeti (open source) and adapt to FALCON sigs. Saves substantial consensus engineering — Mysten Labs has spent years getting the algorithm correct.
- Option B: Write from scratch for full control. Larger surface to audit, more bugs to find.
Recommendation: Option A for v1. The work is audit + adaptation for FALCON sigs; correctness of the core algorithm leverages Mysten Labs' existing engineering.
References & Cross-References
- Full design: DESIGN.md §Consensus
- Threat model (consensus threats): THREAT_MODEL.md §Consensus Layer
- Failure scenarios: FAILURE_SCENARIOS.md
- Chain halt: CHAIN_HALT.md
- Slashing: SLASHING.md
- Validator lifecycle: VALIDATOR_LIFECYCLE.md
- Research papers:
- Mysticeti (Babel et al., 2024) —
https://arxiv.org/abs/2310.14821 - Bullshark (Spiegelman et al., 2022)
- Narwhal (Danezis et al., 2021)
- Mysticeti (Babel et al., 2024) —
State Sync & Chain Halt
This chapter covers how new nodes join the network (state sync) and what happens when consensus encounters problems (chain halt + recovery). Both are operational concerns that the design must address explicitly — the HotStuff pre-pivot architecture lacked clear procedures for both, contributing to the wedges that motivated the pivot.
Part 1: State Sync
The Problem
At the chain's sustained throughput, replaying every block from genesis is infeasible (the transaction count runs into the trillions per year). A new node joining the network needs a way to reach current state without full replay.
Three Sync Modes
| Mode | Use Case | Time |
|---|---|---|
| Full sync (genesis replay) | Archive nodes only | Infeasible at high TPS |
| Snapshot sync (default) | Most full nodes, new committee joiners | ~30-60 min on commodity |
| Light client sync | Mobile wallets, browser, dApp backends | Seconds-minutes |
Snapshot Architecture
Decoupled signing and chunk generation:
- Committee signs state root (cheap, every epoch boundary)
- Volunteers generate chunks (heavier, daily cadence)
#![allow(unused)] fn main() { struct SnapshotManifest { epoch: u64, snapshot_state_root_blake3: Hash, snapshot_state_root_poseidon2: Hash, chunk_manifest: Vec<ChunkRef>, current_committee_pubkeys: Vec<FalconPubkey>, // chain-of-trust signatures: Vec<FalconSig>, // ≥85 from prior committee } }
Why dual roots: Blake3 for fast native verification by syncing nodes; Poseidon2 for future ZK light-client compatibility.
Snapshot Cadence
- Committee root signing: every epoch boundary (cheap, ~5 KB manifest)
- Chunk publishing: every 8 epochs (~daily) by volunteer infrastructure
- Tail sync window: up to 24 hours of txs to catch up
Verification Flow
Phase 1: Discover & Verify Manifest
1. Bootstrap from seed peers
2. Discover manifest URLs/hashes from peers
3. Download signed manifest (~5 KB)
4. Verify ≥85 FALCON sigs against trusted committee pubkeys
Phase 2: Download Chunks
5. Discover peers serving snapshot
6. Download chunks in parallel (4 MB each)
7. Verify each chunk_hash against manifest
8. Bad chunks → ban peer, retry
Phase 3: Reconstruct State
9. Apply chunks to JMT
10. Compute Blake3 state root locally
11. Compare to manifest
12. Accept if match
Phase 4: Recent Sync (Tail)
13. Download blocks from snapshot point to current
14. Replay txs against snapshot state
15. Reach current state
Phase 5: Active Operation
16. Subscribe to gossip; begin participation
Chain-of-Trust Bootstrap
A new node verifies the chain of snapshot manifests from genesis:
Genesis block: contains committee_0.pubkeys (hardcoded)
↓
Snapshot at epoch 8: signed by committee 0, contains committee_8.pubkeys
↓
... etc forward, each signed by prior committee
For nodes that prefer speed over trustless verification: weak subjectivity checkpoints are published by foundation + reputable infrastructure providers. New nodes can trust a recent checkpoint and sync from there.
Light Client Mode
For mobile wallets, browser dApps:
- Storage: block headers only + cared-about accounts
- Operations: verify FALCON sigs on headers (~7ms), query accounts via JMT inclusion proofs
- Bandwidth: ~600 KB/year typical wallet usage
Time Estimates (Commodity, 100 Mbps)
Bootstrap from genesis (small): ~5 seconds
Manifest verification (85 FALCON): ~7 ms
Snapshot download (3 GB): ~4 minutes
JMT reconstruction: ~5 minutes
Recent tail sync (8 epochs): ~30 minutes
Total: ~40 minutes
See STATE_SYNC.md for complete protocol details.
Part 2: Chain Halt + Recovery
The HotStuff pre-pivot architecture suffered persistent wedges with no clear halt → investigate → recover procedure. The team patched live, accumulating safety subtleties. Pyde's post-pivot design EXPLICITLY:
- Separates three halt types
- Defines authority + procedure for each
- Builds drills into the operational plan
Three Halt Types
| Type | Trigger | Severity | Authority |
|---|---|---|---|
| Soft stall | Network / quorum issues | Liveness only | Emergent |
| Hard halt | Contradictory state roots, equivocation cluster | Safety risk | Protocol-detected automatic |
| Emergency halt | Critical bug, active exploit, hard-fork prep | High intentional | Governance multisig (7-of-12) |
Detection
Soft stall (automatic):
- No commit > 5 rounds (~5 sec)
- <85 vertices certified
- Active committee count < 86
Hard halt (automatic):
- State root divergence (2+ signed contradictory roots)
- Equivocation cluster (10+ in single epoch)
- DKG output mismatch
- Execution layer critical invariant violation
- DAG fork detected (should be impossible)
Emergency halt (manual):
- Critical bug discovery (off-chain)
- Active exploit
- Hard-fork coordination
What Happens During Halt
| Activity | Soft | Hard | Emergency |
|---|---|---|---|
| Vertex production | Continues (no quorum) | Stops | Stops |
| Commits | Paused | Paused | Paused |
| Tx submission | Queued | Queued | Queued |
| Decryption ceremonies | Paused | Stopped | Stopped |
| Slashing evidence acceptance | Continues | Continues | Continues |
| Gossip | Continues | Continues | Continues |
Key invariant: slashing evidence accepted during halt — attackers cannot escape consequences by triggering a halt.
Recovery Procedures
- Wait it out (soft stalls) — auto-recover
- Software update + replay (hard halts from bugs) — patch, verify, resume
- Rollback (max 1 epoch back, governance authorized) — controversial but bounded
- Hard fork (irreconcilable splits) — coordinated upgrade
- Emergency unhalt (false positives) — multisig releases
Rollback Policy
Bounded operational pragmatism:
- Maximum rollback window: 1 epoch (~3 hours)
- Within window: governance multisig can authorize
- Beyond window: only hard fork (community coordination required)
This is "weak finality with sunset" — operational flexibility for early detection without arbitrary commit reversibility. Industry standard pattern.
Test Plan
Mandatory drills before mainnet:
- Soft stall: deliberately offline 43 validators
- Hard halt: inject state divergence
- Emergency halt: practice multisig coordination
- Rollback: 1-epoch procedure
- Hard fork: coordinated upgrade
Frequency: quarterly in testnet, annually in mainnet. Runbooks per scenario, updated after every drill.
The HotStuff Lesson Applied
HotStuff broke because there was no clear halt procedure — patches accumulated under pressure. Pyde now has:
- Automatic detection of safety violations
- Explicit halt classification
- Pre-rehearsed recovery procedures
- Drill schedule
See CHAIN_HALT.md and FAILURE_SCENARIOS.md for complete operational specs.
References
- Full state sync spec: STATE_SYNC.md
- Full halt spec: CHAIN_HALT.md
- Failure scenarios + drills: FAILURE_SCENARIOS.md
- Validator lifecycle (jail mechanics): VALIDATOR_LIFECYCLE.md
Chapter 8: Cryptography
Pyde's cryptographic stack is post-quantum from genesis. There are no elliptic curves anywhere in the protocol — no secp256k1, no ed25519, no BLS12-381. Every primitive used to authenticate transactions, exchange keys, hash state, or prove randomness is built on lattices or hash functions.
This chapter specifies every primitive with the parameters Pyde actually ships, where they live in the codebase, and how they fit together.
8.1 Design Principles
Three constraints shape every choice:
-
Post-quantum security. Every primitive must resist both classical and known quantum attacks (Shor for factoring/DLP, Grover for brute force). This rules out RSA, ECDSA, EdDSA, BLS, ECDH, and anything else built on elliptic curves or integer factorization.
-
No trusted setup. No ceremony, no toxic waste. Every public parameter is either a NIST standard or a transparent algebraic constant.
-
Hybrid hashing — Blake3 for speed, Poseidon2 for ZK. Bitwise hashes (Blake3) saturate modern CPUs at multi-GB/s and dominate the high-volume native paths (JMT internals, gossip de-dup, batch hashes). Algebraic hashes (Poseidon2) are 30-50× slower in native execution but roughly 1000× cheaper inside an algebraic constraint system (STARK, future ZK validity proof). Pyde uses both: Blake3 where the work is off-chain or committee-signed, Poseidon2 where the hash may be exposed to a ZK circuit (state root, address derivation, signature payloads).
Traditional blockchain crypto stack:
Signatures: ECDSA (secp256k1) broken by quantum
Key exchange: ECDH broken by quantum
Hashing: Keccak-256 quantum-safe; not ZK-native
Randomness: BLS-based VRF broken by quantum
Pyde crypto stack:
Signatures: FALCON-512 lattice (NIST FIPS 206)
Key exchange: Kyber-768 / ML-KEM lattice (NIST FIPS 203)
Hashing: Blake3 + Poseidon2 hybrid: speed + ZK-friendly
Blake3 (Goldilocks-free, ~3 GB/s)
JMT internals, batch hashes, vertex hashes, gossip
Poseidon2 (Goldilocks field, ZK-native)
state root, addresses, MAC, VRF output, RNG mix
Threshold: Shamir over Goldilocks + Kyber + Poseidon2 KDF/MAC
PSS resharing: Lagrange interpolation over Goldilocks
Randomness: Lattice VRF (FALCON-proof + Poseidon2 output)
Symmetric: AES-256-GCM (hardware-accelerated)
The whole stack lives under crates/crypto.
8.2 FALCON-512: Digital Signatures
FALCON (Fast Fourier Lattice-based Compact Signatures over NTRU) is Pyde's
signature scheme. NIST standardized it as part of FIPS 206. Pyde uses the
FALCON-512 parameter set (LOGN = 9, dimension 512).
Why FALCON-512 over Dilithium / SPHINCS+
| Scheme | Pubkey | Signature | Verify time | Notes |
|---|---|---|---|---|
| FALCON-512 | 897 B | 600–900 B | very fast | smallest sigs, lattice (NTRU) |
| Dilithium-2 | 1312 B | 2420 B | fast | larger sigs, module-LWE |
| SPHINCS+-128 | 32 B | 7856 B | slow | hash-based, huge sigs |
A blockchain hashes signatures into every transaction, every consensus vote, and every finality certificate. A 666 B FALCON sig × 128 committee × per-slot finality cert × 10K blocks/hour adds up — Dilithium's 2420 B would inflate that by 3.6×, SPHINCS+ by ~12×. FALCON's compactness is what keeps the bandwidth budget reasonable.
Parameter set
| Parameter | Value |
|---|---|
| Polynomial degree n | 512 |
| Modulus q | 12,289 |
| Public key | 897 bytes |
| Secret key | 1,281 bytes |
| Signature | 600–900 bytes (variable, accepted) |
| Security level | NIST Level 1 (128-bit post-quantum) |
API
crates/crypto/src/falcon.rs exposes:
#![allow(unused)] fn main() { pub fn falcon_keygen() -> (FalconPublicKey, FalconSecretKey); pub fn falcon_sign(sk: &FalconSecretKey, msg: &[u8]) -> FalconSignature; pub fn falcon_verify(pk: &FalconPublicKey, msg: &[u8], sig: &FalconSignature) -> bool; pub fn falcon_batch_verify(items: &[(&FalconPublicKey, &[u8], &FalconSignature)]) -> bool; }
Determinism
Signing is deterministic. The implementation ties the FALCON Gaussian sampler
to a deterministic context derived from the input message, so the same
(secret_key, message) always produces the same signature. This is what
makes the lattice VRF (§8.7) work — the output is a deterministic function of
the inputs.
The domain-separation tag b"pyde-falcon-v1" is mixed into the signing
context to prevent cross-protocol signature reuse.
Where FALCON-512 is used
- Transaction signing — every transaction carries a FALCON-512 sig from the sender's account.
- Vertex production — every DAG vertex is FALCON-signed by its producer.
- State-root attestations — committee members sign
(wave_id, blake3_state_root, poseidon2_state_root)after each commit; ≥ 85 sigs constitute theHardFinalityCert. - Decryption share authentication — threshold partial decryptions are FALCON-signed by their producer.
- PSS resharing contributions — contributors sign their shares.
- P2P peer authentication — the FALCON handshake (
crates/net/src/auth.rs). - VRF proofs — every VRF output is paired with a FALCON proof.
- Slashing evidence — submitters sign their evidence transactions.
Batch verification
falcon_batch_verify checks an array of (pk, msg, sig) triples
sequentially. The current implementation is not algebraically batched —
it returns true only if every individual verification succeeds. Algebraic
batch verification (sharing FFT operations across signatures) is on the
post-mainnet hardening list; the sequential version is correct and meets the
current per-block budget.
8.3 Kyber-768 / ML-KEM: Key Encapsulation
Kyber is Pyde's key encapsulation mechanism. NIST standardized it as ML-KEM under FIPS 203. Pyde uses the Kyber-768 parameter set, NIST Security Level 3.
What is a KEM?
A KEM lets two parties agree on a shared secret without the symmetric key
ever crossing the wire. Alice runs encaps(pk) -> (ciphertext, shared_secret)
and sends the ciphertext to Bob. Bob runs decaps(sk, ciphertext) -> shared_secret. They now share a 32-byte symmetric key, which Pyde uses as
the AES-256-GCM key for the actual payload encryption.
Parameters
| Parameter | Value |
|---|---|
| Module dimension k | 3 |
| Polynomial degree n | 256 |
| Modulus q | 3,329 |
| Public key (encaps key) | 1,184 bytes |
| Secret key (decaps seed) | 64 bytes (full key derived on demand) |
| Ciphertext | 1,088 bytes |
| Shared secret | 32 bytes |
| Security level | NIST Level 3 (192-bit post-quantum) |
API
crates/crypto/src/kyber.rs:
#![allow(unused)] fn main() { pub fn kyber_keygen() -> (KyberPublicKey, KyberSecretKey); pub fn kyber_encapsulate(pk: &KyberPublicKey) -> (KyberCiphertext, SharedSecret); pub fn kyber_decapsulate(sk: &KyberSecretKey, ct: &KyberCiphertext) -> SharedSecret; }
The dependency is ml_kem = "0.3.0-rc.0" — a release-candidate of the NIST
final standard. Upgrading to the stable release once published is tracked as
post-mainnet hardening (task 057 in the mainnet plan).
Where Kyber is used
- P2P transport key exchange. When two nodes establish a libp2p connection, the QUIC handshake uses a hybrid Ed25519 + Kyber key exchange (Ed25519 for the libp2p PeerId, Kyber for forward-secure session keys). See Chapter 12.
- Threshold encryption for the encrypted mempool. The committee's threshold public key is a Kyber-768 key whose secret has been Shamir-split across 128 validators. See §8.5.
8.4 Hashing: Blake3 + Poseidon2
Pyde uses two hash functions, each chosen for a class of paths:
| Function | Speed (native) | ZK cost (constraints) | Used for |
|---|---|---|---|
| Blake3 | ~3 GB/s | ~150k per hash (huge) | JMT internal nodes, batch hashes, vertex hashes, gossip de-dup, RocksDB keys |
| Poseidon2 | ~60 MB/s | ~400 (small) | State root commitment, address derivation, threshold MAC, VRF output, FALCON sig hashing inside ZK circuits, poseidon2 WASM host function |
Blake3
Blake3 is the BLAKE family successor — based on the BLAKE2 compression function arranged as a parallelizable Merkle tree, with hardware acceleration on every modern CPU. Pyde uses Blake3 in its default configuration (256-bit output) for every hash that lives entirely off-chain or inside a trusted committee-signed structure.
Key Pyde-specific uses:
- JMT internal nodes —
blake3_pair(left, right)per Merkle level. At commodity CPU speed, an entire JMT update batch hashes in microseconds. - Batch hashes referenced from vertices — the worker batches transactions and identifies each batch by its Blake3 hash.
- Vertex hashes in the DAG — every consensus vertex is identified by its Blake3 hash.
- Gossip message de-duplication — Gossipsub uses Blake3 to detect duplicate broadcasts.
- RocksDB cache keys — Blake3 fingerprint of (key, version) for the LRU value cache.
Poseidon2: ZK-Friendly Hashing
Poseidon2 is the algebraic hash function used on paths that may be exposed to a ZK circuit, plus a handful of legacy paths kept for compatibility.
Why not Keccak or SHA-256?
Inside an algebraic system (a STARK, an MPC protocol, a future ZK validity proof), bitwise hash functions like Keccak-256 are catastrophically expensive — roughly 150,000 algebraic constraints per Keccak hash compared to a few hundred for Poseidon2. Even though Pyde doesn't ship a STARK at mainnet, the threshold-encryption MAC and the lattice VRF both benefit from a hash that's cheap inside an algebraic field, and the JMT itself amortizes the per-Merkle work better when the hash is field-native.
Construction
Poseidon2 is a sponge construction over a prime field. Pyde uses the
Goldilocks field (p = 2^64 − 2^32 + 1) because:
- Single field elements fit in a 64-bit register.
- Modular reduction is a shift-and-subtract.
- Hardware AES is independent of this field, so we can use both efficiently.
Parameters
| Parameter | Value |
|---|---|
| Field | Goldilocks (p = 2^64 − 2^32 + 1) |
| State width | 8 field elements (≈ 512 bits) |
| Rate | 4 field elements (256-bit absorb) |
| Capacity | 4 field elements |
| External rounds | 8 (4 initial + 4 terminal) |
| Internal rounds | 22 |
| S-box | x^7 (coprime to p − 1) |
| Output size | 4 field elements (256 bits) |
| Security level | 128-bit collision resistance |
(Verified in crates/crypto/src/poseidon2.rs test suite.)
API
#![allow(unused)] fn main() { pub fn poseidon2_hash(data: &[u8]) -> Hash256; pub fn poseidon2_pair(left: Hash256, right: Hash256) -> Hash256; pub fn poseidon2_many(hashes: &[Hash256]) -> Hash256; }
Domain separation is built into the encoding: variable-length inputs are length-prefixed before sponge absorption, and field elements are packed 7 bytes at a time (avoiding values that exceed the Goldilocks modulus).
Where Poseidon2 is used
- State root commitment — the dual-rooted state has a Poseidon2 root alongside the Blake3 root, signed by the committee.
- Account address derivation —
Poseidon2(falcon_pubkey). - CREATE / CREATE2 address derivation —
Poseidon2(deployer || nonce)orPoseidon2(0xFF || deployer || salt || code_hash). - Storage key derivation —
Poseidon2(contract, slot)for single fields, doubled for maps. Encoded as build-time constants by theotigendeveloper toolchain's state binding generator. - Transaction hashing — the canonical tx hash used for replay prevention and the wallet's signing target.
- Threshold MAC —
Poseidon2(0xFF...0xFF || secret || ciphertext). - VRF output —
Poseidon2(domain || fingerprint || input). - Epoch randomness combination —
Poseidon2_many(sorted_shares). poseidon2WASM host function — exposed to user-space contracts via the host-function ABI.
8.5 Threshold Encryption (Mempool MEV Protection)
Threshold encryption is what lets the encrypted mempool work: messages are encrypted such that no single validator (or coalition of < 85) can decrypt, but the active committee acting collectively can.
Construction
The scheme combines three pieces:
- Shamir Secret Sharing over the Goldilocks field — splits a secret into 128 shares of which any 85 reconstruct.
- Kyber-768 KEM — the underlying public-key primitive.
- Poseidon2 as a counter-mode keystream and as the MAC.
Implementation: crates/crypto/src/threshold.rs.
Setup (per epoch)
1. Generate a Kyber-768 keypair: (pk, sk_seed)
2. Split sk_seed into 128 shares using Shamir SSS:
- Random degree-(t-1) polynomial f over Goldilocks where t = 85
- f(0) = sk_seed
- share_i = (i, f(i)) for i in 1..=128
3. Distribute share_i to validator i
4. Publish pk as the committee's threshold public key
Encryption (user wallet)
(ciphertext, shared_secret) = Kyber.Encaps(pk)
keystream = Poseidon2_keystream(shared_secret, message_length)
encrypted_payload = message XOR keystream
mac = Poseidon2(0xFF...0xFF || shared_secret || ciphertext)
wire = (ciphertext, encrypted_payload, mac)
Decryption (committee)
For each ciphertext in the encrypted block:
Each validator i computes a blinded share:
blinded_i = raw_share_i + H(ct_hash || i || elem_idx)
Validator broadcasts blinded share on the consensus channel.
Combiner collects >= 85 shares, unblinds them by subtracting the
same H() values, then Lagrange-interpolates at x=0 to recover the
Kyber decapsulation seed.
Kyber.Decaps(seed, ciphertext) -> shared_secret
Verify MAC; on success, decrypt payload with the keystream.
Share blinding
Each share is blinded with a per-ciphertext, per-element mask
(H(ct_hash || validator_idx || element_idx)) before transmission. This
prevents a validator's share from ciphertext A from being reused against a
different ciphertext B — even if a validator's share leaked, an attacker
couldn't apply it to other blocks. The combiner has the ciphertext and can
unblind during recovery.
Parameters
| Parameter | Value |
|---|---|
| Underlying KEM | Kyber-768 |
| Committee size n | 128 |
| Threshold t | 85 (~2/3, matches BFT quorum) |
| Per-share size | ~256 bytes (blinded) |
| Decryption latency | ~10–15 ms once t shares present |
8.6 PSS — Proactive Secret Sharing and Resharing
The committee rotates each epoch; the threshold public key does not change. PSS is what makes that work — at every epoch boundary the shares are refreshed without anyone learning the underlying secret.
Why PSS
Without PSS, every committee rotation would require a fresh distributed key
generation (DKG), which is O(n^2) interactive and slow. PSS achieves the
same goal with a single round of asynchronous contributions per validator.
Same-committee refresh
Used for routine forward-security refresh:
Each member generates a degree-(t-1) polynomial f_i with f_i(0) = 0.
Each member sends f_i(j) to every other member j.
Each member j updates: new_share_j = old_share_j + Σ f_i(j)
Because every f_i(0) = 0, the underlying secret is unchanged.
But every share is now drawn from a fresh combined polynomial.
The verification check verify_refresh_contribution confirms the first
t evaluations interpolate back to zero — catching contributors who tried
to inject a non-zero free term.
Cross-committee resharing
Used at epoch boundaries when membership changes:
Each old member i with share s_i picks a fresh degree-(new_t - 1)
polynomial g_i with g_i(0) = s_i. They evaluate at the indices of
the new committee and ship the resulting sub-shares.
Each new member j collects threshold contributions, applies a
canonical-subset rule (lowest-from_old_index first), and aggregates:
new_share_j = Σ (lambda_i × g_i(j))
where lambda_i are Lagrange coefficients at x=0 over the OLD indices.
Result: H(0) = original secret; H is the new polynomial; the new
committee sits on H.
The canonical-subset rule is critical. Different new members must
deterministically agree on which t contributions to use, or they end up on
different polynomials. The rule: sort contributions by from_old_index, take
the first t. This is implemented as canonical_resharing_subset() in
crates/crypto/src/threshold.rs.
The aggregation delay
Because the network delivers contributions asynchronously, every new member
waits RESHARE_AGGREGATION_DELAY_ROUNDS = 5 rounds after entering the new
epoch before aggregating. This guarantees that the same canonical set is
visible to every new member when aggregation begins.
Known limitation: no VSS / KZG commitments
The current verify_refresh_contribution and verify_resharing_contribution
detect polynomial inconsistency — if the sub-shares aren't all on the
claimed polynomial, the check fails. They do not detect a malicious
member who consistently presents a polynomial whose constant term is not
their actual share s_i. This would silently cause the new committee to
derive shares of a different secret, and threshold decryption would stop
working at the start of the next epoch.
The mitigation requires Pedersen or KZG commitments on the shares — a substantial crypto upgrade. For mainnet, the assumption is "committee-member compromise is rare," and any such corruption surfaces as a hard decryption failure within the first block of the affected epoch (highly visible). The upgrade is tracked as post-mainnet research.
8.7 Lattice VRF
Pyde's VRF is built on FALCON-512. The construction:
Output (deterministic):
fingerprint = Poseidon2("pyde-vrf-output-v1" || sk_bytes)
output = Poseidon2("pyde-vrf-output-v1" || fingerprint || input)
Proof:
msg = "pyde-vrf-proof-v1" || pk || input || output
proof = falcon_sign(sk, msg)
Verify(pk, input, output, proof):
msg = "pyde-vrf-proof-v1" || pk || input || output
return falcon_verify(pk, msg, proof)
Properties
| Property | Why it holds |
|---|---|
| Deterministic | Output is a Poseidon2 hash of (sk-derived) constants + input |
| Unpredictable | An attacker without sk cannot compute fingerprint |
| Verifiable | Anyone with pk can verify the FALCON sig over the input/output |
| Post-quantum | Inherits FALCON's NTRU-lattice security |
Where the VRF is used
- Anchor selection (indirect). Each round, the canonical anchor is
computed as
Hash(beacon, round, prev_state_root) mod 128(see Chapter 6 §3). The beacon itself is the threshold-aggregated VRF output of the prior epoch's committee — so VRF underpins anchor selection one step removed, not per-round. - Epoch randomness contributions. Each member of the previous epoch's committee contributes a VRF share that, combined with 84 others, seeds the next epoch's beacon.
- Committee selection scoring. At each epoch boundary, every registered
validator gets a VRF score from
epoch_randomness || "committee"; the uniform-random subset of eligible validators chosen by this score forms the next committee.
8.8 Symmetric Encryption: AES-256-GCM
All symmetric encryption uses AES-256-GCM:
- Threshold-encrypted transaction payloads. Once the Kyber KEM gives the wallet a 32-byte shared secret, the payload is encrypted with AES-256-GCM under that secret.
- P2P channel encryption (after the libp2p QUIC handshake — see Chapter 12).
- Wallet keystore encryption (
crates/pyde-rust-sdk/src/wallet.rs).
Properties
- 256-bit key (128-bit post-quantum security against Grover).
- AEAD — authenticated encryption with additional data; tampering is detected.
- AES-NI hardware acceleration on every modern CPU.
8.9 Key Derivation and Address Format
From keypair to address
Master seed (user-provided or random)
-> SHAKE-256 (with domain separator) -> FALCON keygen seed
-> FALCON-512 keygen
|
+-> Public key (897 bytes)
|
+-> Secret key (1281 bytes)
Address derivation:
EOA address = Poseidon2(falcon_public_key) // 32 bytes
CREATE = Poseidon2(deployer_address || nonce)
CREATE2 = Poseidon2(0xFF || deployer || salt || code_hash)
Why 32-byte addresses
Pyde uses 32 bytes (the full Poseidon2 output) instead of Ethereum's 20-byte truncation. Three reasons:
- Birthday-bound margin. A 20-byte address has 80-bit collision resistance. Marginal at chain scale; decisively safer at 128 bits.
- Native output size. Poseidon2 naturally outputs 4 Goldilocks field elements (≈ 256 bits = 32 bytes). Using the full output avoids a truncation step.
- Simpler key derivation. Every key derivation in the protocol produces 32 bytes; addresses match.
Wallet display
Addresses are stored and serialized as raw 32-byte values. Wallets render
them in hex (0xabc...123) or in a Bech32m-style human-readable format with
the pyde1... prefix for safety against typos. The choice of display format
is a wallet-side concern; the protocol doesn't care.
8.10 The Stack at a Glance
+----------------+ +----------------+
| FALCON-512 | | Kyber-768 |
| sigs | | KEM |
+----------------+ +----------------+
| |
v v
tx sigs P2P session keys
vertex sigs threshold pubkey (mempool)
state root attest |
PSS contributions |
| |
+-> Lattice VRF (FALCON sign + Poseidon2 output)
anchor seeding, epoch randomness, committee scoring
+----------------+ +----------------+
| Blake3 | | Poseidon2 |
| (high-volume) | | (Goldilocks) |
+----------------+ +----------------+
| |
v v
JMT internals state root commit,
batch hashes addresses, storage keys,
vertex hashes MAC, VRF output, RNG mix
gossip dedup poseidon2 host function
+----------------+
| AES-256-GCM |
+----------------+
payload AEAD,
wallet keystore
No elliptic curves anywhere. No trusted setup. Every primitive is either a NIST FIPS-standardized scheme (FALCON, ML-KEM, AES) or a widely-studied algebraic construction (Poseidon2, Shamir SSS, PSS).
8.11 Cryptographic Agility
Each primitive is accessed through a small, well-defined module
(crates/crypto/src/falcon.rs, kyber.rs, poseidon2.rs, threshold.rs,
vrf.rs). If a serious break is discovered in any one of them, the
affected module can be replaced through a protocol upgrade without
restructuring the rest of the system.
Because the address format is bound to a hash of the public key (not the key itself), a future migration to a different post-quantum signature scheme would change addresses — but the upgrade path is well-defined: a one-time key-rotation transaction signed by both old and new keys, with the address derivation domain-separated by scheme version.
That migration is not planned. NIST's FIPS standardization is the credible long-term anchor for FALCON and Kyber, and switching from them would only happen if a substantive cryptanalytic break appeared.
Summary
| Primitive | Use | Where |
|---|---|---|
| FALCON-512 | All signatures (txs, vertices, state roots, attestations) | crates/crypto/src/falcon.rs |
| Kyber-768 / ML-KEM | P2P session keys + threshold mempool encryption | crates/crypto/src/kyber.rs |
| Blake3 | High-volume native hashes (JMT, batches, vertices, gossip) | crates/crypto/src/blake3.rs |
| Poseidon2 | ZK-bearing hashes (state root, addresses, MAC, VRF, opcode) | crates/crypto/src/poseidon2.rs |
| Threshold scheme | 85-of-128 mempool decryption (Kyber + Shamir) | crates/crypto/src/threshold.rs |
| PSS (refresh + reshare) | Forward security + cross-committee handoff | crates/crypto/src/threshold.rs |
| Lattice VRF | Anchor seeding, randomness, committee score | crates/crypto/src/vrf.rs |
| AES-256-GCM | Symmetric AEAD (mempool payload, wallet keystore) | (via the aes-gcm crate) |
The next chapter walks through MEV protection end-to-end — how these primitives combine in the DAG commit pipeline to make front-running and sandwich attacks not "discouraged," but unexpressible.
Chapter 9: MEV Protection
Maximal Extractable Value is the single largest structural problem in production blockchain design. On Ethereum it transfers somewhere between $1B and $3B annually from ordinary users into the pockets of searchers, builders, and validators. On Solana the Jito stack is a tip auction by another name. Every "fix" attempted at the application layer ultimately relies on someone who can see your transaction before it lands.
Pyde does not mitigate MEV. It removes the mechanism by which it is expressible. This chapter walks through the four interlocking pieces that make front-running, sandwich attacks, JIT liquidity sniping, and ordering bribery infeasible at the protocol level — not in policy, in physics.
Post-pivot context. The earlier HotStuff design had a single proposer per slot, which was both the source of and the brake on MEV. After the 2026 pivot to Mysticeti DAG consensus, there is no single proposer to bribe or collude with — each round, every committee member produces a vertex independently, and the canonical order is derived from a deterministically-selected anchor + commit certificate. This makes the MEV story even stronger, but a few details (ordering commitment, mandatory inclusion) have moved from "proposer asserts" to "DAG structurally enforces."
Encryption is optional per-transaction. Users who don't care about front-running (e.g., simple transfers, public DAO votes) can submit plaintext for lower fees and ~0.5-2× higher TPS. Users who do care (swaps, liquidations, arbs) encrypt and pay the threshold-decryption overhead. The protocol supports both.
9.1 The MEV Problem
What MEV looks like
The simplest sandwich attack:
Without MEV protection (Ethereum, Solana):
Mempool:
Alice: Buy 10,000 TOKEN_X at market
Searcher sees Alice's tx and bundles:
Searcher: Buy 5,000 TOKEN_X <- inserted BEFORE Alice
Alice: Buy 10,000 TOKEN_X <- executes at higher price
Searcher: Sell 5,000 TOKEN_X <- inserted AFTER Alice, profits
Result:
Alice pays a worse price.
Searcher pockets the slippage.
Builder/validator extracts a cut via tip or block-bid.
Variants: front-running (just the "Buy before"), back-running (capture an arb the victim's swap creates), JIT liquidity (provide liquidity right before a large swap, withdraw immediately after), liquidation sniping (race other liquidators for a discount).
Why mitigation isn't enough
Every "mitigation" approach in production today shares one defect: at least one party — a builder, a relay, a private-mempool operator — can see your transaction before its position in the block is final.
| Approach | Who still sees the tx |
|---|---|
| PBS / MEV-Boost | Builders + relays |
| Fair ordering | Network observers (latency-exploitable) |
| Batch auctions | Solver (and only fixes one tx type — swaps) |
| Private mempool | Builder still sees |
| Commit-reveal | Adds latency; doesn't address validator games |
As long as anyone can read your transaction before deciding where it goes, MEV extraction is possible.
Pyde's choice: make it information-theoretically impossible for anyone — the proposer, validators, observers — to know what a transaction does until after its position is irrevocably committed.
9.2 The Four Layers
Layer 1: OPTIONAL THRESHOLD-ENCRYPTED MEMPOOL
- Tx payload encrypted with the committee's threshold pubkey (Kyber-768).
- No single party can decrypt; 85 of 128 share-holders required.
- Encryption is opt-in per tx — txs that don't need MEV protection
can be submitted plaintext at lower cost.
- Closes (for encrypted txs): front-running, sandwich, JIT,
liquidation-sniping based on reading mempool contents.
Layer 2: COMMIT-BEFORE-REVEAL ORDERING
- The DAG anchor at round R commits to a canonical subdag ordering
of vertices (and therefore txs) BEFORE decryption shares for that
wave are released.
- The anchor is deterministic from epoch beacon + round; no single
validator chooses it. Decryption shares are piggybacked on
subsequent rounds' vertices and only combine once the subdag is
committed.
- Closes: post-decryption reordering. Because the order is fixed by
the DAG structure before contents are readable, even a colluding
85+ subset cannot rewrite the order after seeing contents.
Layer 3: STRUCTURAL INCLUSION (DAG)
- Every vertex from round R includes references to >= 85 parent
vertices from round R-1. A tx introduced into the DAG via any
honest member's batch is committed once any committed anchor
references the path containing it.
- There is no "proposer" who can selectively omit. Censorship
requires >= 44 validators (the equivocation threshold) to refuse
to reference the tx — a structurally visible attack.
- Closes: single-actor censorship of decryptable txs.
Layer 4: NO TIPS, NO PRIORITY FEES
- The wire format has no field for a tip, priority fee, or out-of-band
payment to any party.
- The fee is exactly gas_used * base_fee.
- Closes: bribery channels for ordering.
Each layer closes attacks the others alone could not. Removing any one re-opens a class of MEV.
9.3 Layer 1 — Threshold-Encrypted Mempool
The wire shape
Plaintext (visible from submission):
from 32 B (Poseidon2 of the FALCON pubkey)
nonce u64
gas_limit u64 (>= 21,000, <= 1.6B gas ceiling)
access_list Vec (state slots the tx will touch)
deadline u64? (wave height after which the tx is invalid)
chain_id u64
signature ~666 B (FALCON-512 over the canonical hash of all fields)
Encrypted (Kyber ciphertext + AES-256-GCM payload):
to 32 B
value u128
calldata Vec<u8>
fee_payer Sender | GasTank | Paymaster(addr)
tx_type Standard | Deploy | Batch | Stake* | Vote*
The committee's threshold public key is a Kyber-768 key whose secret has been Shamir-split across the 128 active validators (see Chapter 8). Any 85 share-holders combine to decrypt; any 84 learn nothing.
What's visible vs what's hidden
| Field | Visible | Why |
|---|---|---|
from | yes | Needed for signature verification + nonce window check |
nonce | yes | Replay protection (must fit the bitmap window) |
gas_limit | yes | Block gas accounting at proposal time |
access_list | yes | Prefetch hint for cache warm-up (never affects correctness) |
deadline | yes | Mempool eviction of expired txs |
chain_id | yes | Cross-chain replay protection |
signature | yes | Validates the whole tx |
to | no | Reveals counterparty |
value | no | Reveals transfer amount |
calldata | no | Reveals function call + arguments + intent |
The access list reveals which state slots the transaction touches, but not what it does to them. An observer can see "this tx touches the DEX contract's reserve slots" but cannot tell whether it's a buy, a sell, a liquidity add, or a fee claim.
Access-list padding (optional)
If a contract's slot pattern is unusually distinctive (rare), the wallet can pad the access list with read-only decoy slots. The decoys cost a small amount of gas (one Sload per slot, ~100 gas each) but flatten the access profile. Most contracts use overlapping slots for many operations, so padding is not needed in practice.
What MEV searchers see in the mempool
Pyde encrypted mempool (what an observer scrapes):
tx_hash | sender | gas_limit | access_list | encrypted
--------+----------+-----------+--------------------+-----------
0xabc...| Alice | 300,000 | [(DEX, [s7, s12])] | 0x8f3a...
0xdef...| Bob | 100,000 | [(NFT, [s1])] | 0x2c7b...
0x123...| Carol | 500,000 | [(DEX, [s7, s12])] | 0x91de...
The observer learns access patterns. They cannot construct an attack bundle because they don't know the swap direction, swap size, slippage tolerance, or token pair.
Anti-spam and per-sender rate limits
To stop a malicious user from flooding the mempool with garbage ciphertexts:
| Limit | Default | Why |
|---|---|---|
DEFAULT_MAX_TX_PER_WINDOW_PER_SENDER | 10 tx / 1 s | Token-bucket burst limit |
DEFAULT_MAX_CONCURRENT_PER_SENDER | 100 in pool | Cap concurrent pending txs |
RATE_WINDOW_MS | 1000 ms | Token-bucket window size |
Each sender has a SenderQuota tracking timestamp deque + concurrent
count; an add() past the limit returns MempoolError::RateLimited.
Ciphertext binding to FALCON pubkey
Each transaction's signature covers a hash that includes the ciphertext hash (Poseidon2 of the encrypted blob). A relay-inflation spammer who takes someone else's ciphertext and resubmits it under a different sender fails signature verification because the legitimate sender's signature binds the ciphertext to the original sender.
This is what makes per-sender rate limits work — every encrypted tx has exactly one valid sender it can attribute to.
9.4 Layer 2 — Commit-Before-Reveal Ordering (DAG)
In the post-pivot DAG architecture, ordering and decryption are structurally separated by the protocol — no proposer "commits" to an ordering, because there is no proposer. Instead:
-
Round R: every committee member produces one vertex with parent refs and batch refs. Encrypted transactions are referenced by batch hash; their plaintext is unknown to everyone (including the vertex producer, who cannot decrypt alone).
-
Round R+1 to R+3: later rounds reference round-R vertices as parents and accumulate Mysticeti's 3-stage support.
-
Anchor commit at round R+3: the deterministic anchor at round R+3 (selected by
Hash(beacon, round, prev_state_root) mod 128) collects sufficient support, and a canonical subdag traversal emits a fixed ordered list of vertices, batches, and transactions. -
Decryption shares released: committee members compute and broadcast decryption shares for the just-committed wave's encrypted transactions, piggybacked on round-R+4 vertices.
-
85 shares combined: any honest node assembles 85 shares per ciphertext, decrypts, and executes in the canonical order.
Round R : vertices produced (encrypted txs referenced by batch hash;
nobody can read contents yet)
Round R+1 : referencing vertices (still encrypted)
Round R+2 : 2-stage support accumulates
Round R+3 : anchor commit fires -> canonical order LOCKED
Round R+4 : decryption shares released -> contents revealed
The critical property: between the moment the anchor commits and the moment shares combine, the ordering is fixed by the DAG structure. There is no actor with both the ability to read contents AND the ability to alter ordering — those capabilities exist in non-overlapping rounds.
Why this is stronger than commit-then-broadcast
Under a single-proposer commit-then-broadcast scheme, you have to trust that the proposer can't both compute shares early AND alter the commitment. Under the DAG, you don't trust anyone: the order is a deterministic function of vertices that were already in the DAG before contents could be read. No commitment signature is needed, because the commitment is the DAG itself.
Implementation
The canonical subdag traversal and ordering emission live in
crates/consensus/src/subdag.rs. The deferred-decryption pipeline lives
in crates/crypto/src/threshold.rs and crates/consensus/src/wave.rs.
9.5 Layer 3 — Structural Inclusion (DAG)
Under HotStuff, a single proposer could selectively omit txs, motivating the local-view mandatory-inclusion check. Under Mysticeti DAG, there is no single proposer — every committee member produces a vertex each round, each vertex references batches from any worker the producer gossiped with, and every committed wave traverses the entire subdag.
For a transaction to be censored, a coalition of ≥ 44 validators
(equivocation threshold = n - 2f = 128 - 84 = 44) must all refuse to
reference the batch containing it. Below that threshold, ≥ 85 honest
vertices reference it and it lands in some committed subdag.
A tx submitted to ANY honest worker is gossiped to all 128 validators.
Each validator's primary produces a vertex referencing batches from
workers it received from. As long as 85+ committee members eventually
reference the batch (directly or transitively via the parent links),
the tx is committed in the next wave.
Censoring requires 44+ validators to coordinate omission — a structurally
visible attack with multiple independent forks of evidence.
Mempool-level mandatory inclusion (residual)
For tighter guarantees on a per-vertex basis, a validator can still skip or down-weight a vertex that omits txs visible in its local mempool view for >= grace_slots. This is defensive, not necessary for safety — the DAG already guarantees inclusion at the wave level. The check catches single-validator censorship attempts before they require coalition.
The audit logic lives in crates/mempool/src/inclusion.rs.
Cryptographic mempool commitments (post-mainnet)
Cryptographically aggregated mempool commitments (every committee member periodically gossips a hash-set of txs they've seen, then the DAG-level inclusion check is against the union) make censorship coalition-bounded even at the round level. This is tracked for post-mainnet hardening; not needed for safety at launch.
9.6 Layer 4 — No Tips, No Priority Fees
Pyde's gas model has no field anywhere in the wire format for a "priority fee" or "tip." Every transaction pays exactly:
fee = gas_used * base_fee
Where base_fee is algorithmically determined by EIP-1559 (target 400M gas,
4× elastic ceiling, ±12.5% per block adjustment). The only sender-controlled
fee parameter is gas_limit, which is a cap (refunded if execution uses
less).
Why this matters
Even if encryption + commitment + mandatory inclusion fully closed the direct ordering attacks, a tip would re-open the bribery channel. A searcher could pay a committee validator out-of-protocol to delay a tx, to position their own tx first, or to censor a competitor's tx. Tips create the economic incentive for all of those attacks; absent tips, no validator gains anything from any of them.
How ordering happens
Under the DAG, ordering is a deterministic function of the committed subdag — not a proposer choice. The subdag traversal at each commit emits transactions in a canonical order derived from vertex round, member id, and batch sequence. No actor — proposer, validator, observer — chooses positions.
For sequential nonce dependencies (a sender submitting txs n, n+1, n+2
in quick succession), the protocol uses the 16-slot bitmap nonce window
(see Chapter 11) — the txs can be included in any order within the window;
gaps are tolerated.
9.7 The End-to-End Walkthrough
A swap from Alice's wallet through the full pipeline:
Step 1 — WALLET (Alice)
- Build tx: to=DEX, calldata=swap(USDC, PYDE, 1000, min_out=950)
- Call pyde_estimateAccess(tx) -> returns gas + access_list
- Encrypt (to, value, calldata, fee_payer, tx_type) with the committee's
Kyber threshold pubkey: ciphertext + AES-256-GCM payload
- Sign the canonical hash with Alice's FALCON-512 secret key
- Submit via pyde_sendRawEncryptedTransaction
Visible to anyone who scrapes the mempool:
Alice sent a tx. It touches DEX slots [reserve_0, reserve_1, alice_bal].
300,000 gas. 0xpyde1abc... signature.
Hidden:
Direction (buy or sell), size, target tokens, slippage tolerance.
Step 2 — INGRESS VALIDATION (any RPC node)
- chain_id, FALCON sig, nonce window, gas-tank balance, gas ceiling,
deadline, access-list dedup, tx size, calldata size -> all pass
- Forward to a nearby worker; worker batches and gossips
Step 3 — DAG VERTEX PRODUCTION (round R)
- Each committee primary produces ONE vertex this round, with:
batch_refs: hashes of batches containing Alice's tx (and others)
parent_vertex_refs: ≥ 85 prior-round vertex hashes
state_root_sigs: attestations on recent commits
decryption_shares: PARTIAL shares for previously-committed waves
FALCON sig
- Nobody can read Alice's tx contents yet — full ciphertext payload.
Step 4 — DAG ANCHOR COMMIT (round R+3, ~500 ms after submission)
- Deterministic anchor at round R+3:
anchor_member = Hash(beacon, R+3, prev_state_root) mod 128
- Anchor collects Mysticeti 3-stage support -> commit fires
- Canonical subdag traversal emits ordered tx list — including Alice's
Step 5 — THRESHOLD DECRYPTION (rounds R+4 to R+5)
- Committee members compute decryption shares for txs in the just-
committed wave, blinded with H(ct_hash || member_idx || elem_idx),
and piggyback shares on their next vertices.
- Any honest node collects ≥ 85 shares per ciphertext, interpolates,
decrypts with AES-256-GCM. ~10-15 ms once 85th share arrives.
Step 6 — EXECUTION (Block-STM)
- Prefetch the union of declared access lists in one batched
state_cf.multi_get (PIP-3) into the dashmap (PIP-4). Lists are
hints; they never partition the wave or affect correctness.
- Run every decrypted tx in parallel via the Block-STM scheduler:
optimistic execute through an MVCC layer + validate against
canonical tx_index order + cascade-invalidate + re-incarnate on
conflict + fixpoint. Full algorithm in
companion/BLOCK_STM_EXECUTION.md.
- Final state derived from the fixpoint: highest-tx_index's last
write per slot. Execute against pre_state_root → new post_state_root.
- Distribute fees: 70% burn, 20% to current epoch's reward pool, 10% treasury.
(Layer 4: no tip is paid because no tip field exists in the wire format.)
Step 7 — STATE ROOT ATTESTATION
- Each committee member FALCON-signs (wave_id, blake3_state_root, poseidon2_state_root).
- Sigs piggyback on subsequent vertices.
- ≥ 85 sigs -> finality. Typically ~500 ms median end-to-end.
Step 8 — RECEIPT
- Receipt available via pyde_getTransactionReceipt:
success, gas_used, logs, fee_paid, fee_payer, wave_id
At no point in this flow does any party know transaction contents AND have the ability to influence ordering. That conjunction is what MEV requires; the protocol structurally denies it.
9.8 What Each Attack Vector Requires (And Why It Fails)
Front-running Alice's swap requires:
1. Know Alice's intent <- blocked by encryption (Layer 1)
2. Insert before Alice in this block <- blocked by ordering commitment (Layer 2)
3. Get into the block at all <- blocked by mandatory inclusion + sealed block
4. Have economic motive <- blocked by no-tip rule (Layer 4)
Sandwich attack requires:
1. Know the swap direction <- (1) above
2. Insert before AND after <- (2) and the sealed-block invariant
3. Bribe for specific positioning <- (4) above
Censoring a competitor's tx requires:
- Selectively omitting it <- blocked by mandatory inclusion (Layer 3)
Bribing a committee validator for ordering requires:
- A protocol mechanism to pay them <- doesn't exist (Layer 4)
Each attack requires a conjunction of capabilities. Pyde structurally denies at least one element of every conjunction.
9.9 Edge Cases
Insufficient decryption shares
If fewer than 85 valid shares arrive within ~2 rounds (~300 ms) of a commit, decryption fails for that wave. The DAG continues — subsequent waves are unaffected — but the affected txs remain encrypted and stuck in mempool until either enough shares arrive late OR the user resubmits. Non-responsive committee members are tracked toward the liveness slashing threshold (see Chapter 6).
Liveness assumption: as long as 85+ of 128 validators are honest and
online, decryption succeeds. This is the same f < n/3 assumption that
secures consensus.
Invalid decryption shares
A malicious validator could broadcast a fabricated share. The combiner verifies each share's blinding and the recovery's MAC; an invalid share doesn't poison the recovery (Lagrange interpolation over a sufficient honest subset still works). Detected bad shares are tracked toward slashing for "decryption withholding" (2% per offense).
Garbage ciphertexts
A user could submit a ciphertext that decodes but contains junk. After decryption, the AES-GCM authentication tag fails, the tx is invalid, and gas is consumed (sender pays). The mempool's per-sender rate limit (10 tx/s, 100 concurrent) caps the throughput an attacker can sustain.
Epoch boundary transitions
PSS resharing happens at every epoch boundary. The threshold public key is
unchanged across boundaries — wallets continue encrypting against the same
key. The 5-round aggregation delay
(RESHARE_AGGREGATION_DELAY_ROUNDS) ensures every new committee member
agrees on the same canonical contribution set before the new shares
become live.
Committee-member compromise
If a coalition of 85+ validators colluded, they could decrypt early. Even then, the ordering commitment forces them to commit to ordering before decryption — they can't exploit the early read for sandwiching. They could in principle censor (omit txs from blocks they propose), but that fails the mandatory inclusion check on every honest validator's view. The cost of 85+ collusion is 850,000 PYDE at risk plus the slashing exposure of every participant; the gain is sharply bounded by the structural protections.
9.10 Performance Cost
The MEV protection adds latency primarily in the deferred-decryption path:
| Step | Time (typical) | Where it lives |
|---|---|---|
| DAG anchor commit (waves) | ~500 ms median | crates/consensus/src/wave.rs |
| Threshold share computation | ~5 ms per tx (parallel) | crates/crypto/src/threshold.rs |
| Share gossip + 85-of-N collect | ~50-100 ms | piggybacked on next vertices |
| Recovery + AES decrypt | ~5 ms per tx | crates/crypto/src/threshold.rs |
Encrypted txs reach finality + execution ~600-800 ms median (vs ~500 ms for plaintext). Plaintext-only chains pay zero of this overhead.
Throughput impact. Encrypted txs are ~3-5× slower end-to-end than plaintext because the decryption pipeline serializes (shares must be gathered before the wave can execute). Both the plaintext and encrypted v1 throughput targets are to be established by the multi-region performance harness; the encrypted regime lands well below the plaintext one because of this serialization.
The bandwidth cost is per-share data piggybacked on consensus vertices (~250 KB/validator/wave), well within the 500 Mbps committee NIC budget.
9.11 What's Visible vs Hidden — Recap
+-----------------------------+------+------+
| Field | Plain| Enc. |
+-----------------------------+------+------+
| sender | Y | |
| nonce | Y | |
| gas_limit | Y | |
| access_list | Y | |
| deadline | Y | |
| chain_id | Y | |
| signature | Y | |
| to | | Y |
| value | | Y |
| calldata | | Y |
| fee_payer | | Y |
| tx_type | | Y |
+-----------------------------+------+------+
You see who sends, how much gas they're willing to pay, which slots they touch. You don't see what they're doing.
9.12 What This Doesn't Solve
Honest about the limits:
-
Information leakage from access lists. A sufficiently distinctive access pattern can leak operation type. The mitigation is at the contract-design level (DEXes already share the same slots for buys, sells, and liquidity ops in well-designed code) and the wallet level (optional access-list padding).
-
Out-of-protocol coordination. If a user signs an off-chain message saying "I will swap soon," anyone with that information can act on it. The protocol can't prevent users from leaking their own intent.
-
Long-run statistical profiling. A persistent attacker who watches Alice's access patterns over many transactions could infer her behavior. This is a privacy concern, not an MEV one — Alice's individual transactions are still safe from front-running.
-
Searcher-on-searcher games at the DEX/contract level. If a contract has a built-in tip mechanism (priority gas auctions inside the contract itself), Pyde's protocol-level MEV protection doesn't reach into it.
For mainnet, the in-scope guarantees are: no front-running by any committee member, no sandwich attacks composable through the mempool, no censorship of decryptable txs, and no bribery channel for ordering.
Summary
Pyde's MEV protection is not a feature bolted on to an otherwise standard chain. It is a structural property of the protocol arising from the interaction of four mechanisms:
| Layer | Closes | Lives in |
|---|---|---|
| Optional threshold encryption | Reading tx contents pre-inclusion (opt-in) | crates/crypto/src/threshold.rs |
| Commit-before-reveal (DAG) | Reordering after decryption | crates/consensus/src/wave.rs |
| Structural inclusion (DAG) | Single-actor censorship | crates/consensus/src/dag.rs |
| No tips / priority fees | Bribery for ordering | crates/tx/src/fee.rs |
Each layer addresses an attack the others alone cannot stop. Together, MEV extraction is not "discouraged" — it is unexpressible in the protocol.
v1 scope. Local-view mandatory inclusion is implemented and safe (a defensive backstop on top of structural DAG inclusion). Cryptographically aggregated mempool commitments + on-chain censorship slashing are tracked as post-mainnet hardening.
The next chapter covers the gas and fee model that the no-tip rule sits on top of.
Chapter 16: Security
Security is the substrate on which every other property of Pyde rests. This chapter catalogs the realistic attack surface at mainnet, the concrete defense for each class, the invariants that make the BFT safety argument work, and the operational hygiene that keeps a post-launch network healthy.
The scope of this chapter is the shipped mainnet. Where a defense is on the post-mainnet hardening list rather than live, the chapter says so.
Note. This chapter is the narrative security reference. The canonical catalog — ~50 threats by ID, organized by layer, with mitigation cross-references and acknowledged residual risks — lives in companion/THREAT_MODEL.md. External auditors should start with the threat model and use this chapter for context; readers building intuition should start here and dip into the threat model when they want the full catalog.
16.1 Attack Surface
| Attack class | Severity | Primary defense |
|---|---|---|
| 51% / Byzantine takeover | Critical | BFT f < n/3 with equal-vote committee, Mysticeti-style safety |
| Long-range attack | High | Weak-subjectivity checkpoints; hard-finality irreversibility |
| Sybil attack | High | Layered: threshold encryption removes attack incentive + operator-identity cap (max 3/operator) + slashing + minimum stake floor |
| Eclipse attack | High | Layered discovery (no DHT) + FALCON peer auth + sentry pattern |
| DDoS (network-level) | Medium | Rate limiting, peer scoring, per-channel size caps, sentry |
| Front-running / MEV | High | Optional threshold encryption + commit-before-reveal DAG (Ch 9) |
| State manipulation | Critical | JMT batched Merkle proofs, deterministic replay, 2 state roots (Blake3+Poseidon2) |
| Quantum attacks | Critical | Entire stack is post-quantum from genesis (Ch 8) |
| Smart contract exploit | High | Default safety attributes (no reentrancy, checked arithmetic) enforced at runtime via the WASM execution layer |
| VM / runtime exploit | Critical | wasmtime sandbox (production-vetted at Microsoft / Fastly / Shopify), deterministic feature subset enforced, deploy-time import validation |
| Consensus persistence loss | Critical | WriteOptions::set_sync(true) + panic-on-persist-failure |
| Replay across chains | High | Mandatory chain_id in every tx hash |
| Treasury drain | Critical | Multisig-only spend + data_digest audit trail |
| Threshold crypto break | Critical | Hard halt + emergency pause + key rotation procedure |
Each of these is covered in more detail below.
16.2 BFT Safety and Liveness
The guarantee
Safety (Mysticeti DAG): no two conflicting subdag commits or state
roots ever achieve finality at the same wave, provided fewer than
f = ⌊(n-1)/3⌋ = 42 committee members are Byzantine. At n = 128,
this is f ≤ 42, threshold 2f + 1 = 85.
Liveness: the DAG advances and produces commits as long as
85 of 128 committee members are honest and online.
Why it holds
Each vertex carries ≥ 85 parent vertex references. An anchor commit at
round R+3 requires Mysticeti 3-stage support — at least 85 round-(R+1)
vertices that reference the anchor as a parent. Two conflicting commits
of contradictory subdags at the same wave would each need 85+ signing
vertices; the total exceeds n = 128, so at least 85 + 85 − 128 = 42
honest members would have had to equivocate (sign in both forks). Under
the Byzantine bound, at most 42 are adversarial. Equivocation is
slashable evidence at 100% of stake, so the cost is total. ∎
State-root divergence (two contradictory Blake3 state roots both signed by 85 members) is detected automatically and triggers a hard halt (Chapter 7 / companion/CHAIN_HALT.md).
What if more than 1/3 are Byzantine
The protocol cannot promise safety above the 1/3 threshold; that's a mathematical limit of BFT consensus. Defenses:
- Raise the cost. 10M PYDE per committee validator × 43 = 430M PYDE at stake minimum for a safety violation, all slashable at 100%. Slashing evidence can be submitted with a 10% finder's fee, creating economic incentive for whistleblowers.
- Weak-subjectivity checkpoints. If an adversary somehow accumulated ≥ 1/3 and started forking, nodes that sync from a recent checkpoint reject the fork outright (§16.3).
- Hard halt on detected divergence. State root divergence (two signed contradictory roots) triggers an automatic chain halt; the network stops producing commits until the divergence is resolved (Chapter 7).
- Social consensus. As with every BFT chain, the final backstop is human coordination: if the chain demonstrably goes off the rails, the honest majority forks away and the broken chain loses social legitimacy.
16.3 Long-Range Attacks and Weak Subjectivity
The attack
An attacker buys (or otherwise acquires) a majority of validator keys that were active at some point in the past. They create a long alternative chain starting from that point — completely different history, potentially different token holders. If a fresh node syncs without any reference point, it cannot distinguish the real chain from the alternative.
The defense: weak-subjectivity checkpoints
When a commit collects ≥ 85 FALCON state-root signatures, the
validator writes a FinalityCheckpoint to the consensus store:
#![allow(unused)] fn main() { struct FinalityCheckpoint { wave_id: u64, blake3_state_root: Hash, poseidon2_state_root: Hash, } }
(Stored under FINALITY_CHECKPOINT_KEY in
crates/node/src/consensus_store.rs.)
A node that's currently synced will refuse to reorg past the latest
checkpoint. FinalityTracker::can_reorg(wave_id) returns false for any
wave at or before the checkpoint's wave_id.
For a cold-syncing node, the protocol doesn't pick a checkpoint on its own — the node's operator provides a trusted recent block hash from a source they trust (the Foundation website, a public explorer, a known good peer). This is called "weak subjectivity" because new nodes must trust something outside pure protocol to anchor their sync.
The human-trust assumption is narrow: all you need is any one honest, recent observation of the chain. Once anchored, the node enforces its own local checkpoint going forward.
Bootstrap peers
The genesis block hash is built into the client binary — no external
trust needed for it. The MAINNET_BOOTSTRAP and TESTNET_BOOTSTRAP lists
(crates/net/src/discovery.rs) provide starting peers, which provide the
current chain state. A new node combines:
- Genesis block hash (hard-coded).
- Recent weak-subjectivity checkpoint (operator-provided).
- Current peer set (from
bootstrap_peers).
—to pin down which chain is real without requiring a full replay from genesis.
16.4 Sybil Resistance
The attack
An adversary creates many validator identities to dominate consensus —
bypassing the f < n/3 bound by simply being the majority of the
active committee.
The defense: layered, not stake-driven
Pyde's Sybil resistance is intentionally not anchored to stake size. The chain's structural MEV resistance removes the primary attack incentive, which lets the stake floor sit at a modest 10,000 PYDE (single tier) and shifts the security burden onto a stack of qualitative defenses. Five layers:
1. Threshold encryption removes the attack incentive. The dominant reason adversaries attack BFT consensus on production chains is MEV extraction — front-running, sandwich attacks, transaction reordering. On Pyde, this attack value is structurally near-zero. Even a Byzantine 1/3 cannot:
- Decrypt encrypted-mempool ciphertexts (requires 85 of 128 shares — see Chapter 8 §8.5);
- Reorder transactions after the DAG anchor commits the canonical order (Chapter 9 §9.4);
- Profitably front-run any opt-in-encrypted transaction.
This collapses the attack-profit equation that drives Ethereum-scale stake floors (32 ETH → ~$80–120K). Pyde does not need to price stake against MEV profits because there are no MEV profits to be made.
2. Operator-identity cap (max 3 validators per operator).
A Byzantine fork needs f + 1 = 43 of 128 committee slots. Under a
3-per-operator cap, that translates to ≥ 15 distinct KYC'd operator
identities — much harder to manufacture than capital. Identity binding
is enforced via the stake-account-to-operator mapping; high-stake
operators face additional KYC verification at registration.
3. Slashing at 100% on safety violations. Equivocation and bad state-root signatures incur full-stake slashing plus permanent ban (see Chapter 14 §14.5 / companion/SLASHING.md). The 10% finder's fee creates an active whistleblower incentive — every honest node has a financial reason to surface attacker evidence within the 21-day freshness window.
4. Hard-halt detection on state-root divergence. Two contradictory signed state roots trigger an automatic chain halt (Chapter 7 §Part 2). Attackers cannot quietly corrupt state — safety violations are loud, visible, and immediately interrupt block production. The 1-epoch bounded rollback policy contains damage to a narrow window.
5. Minimum-stake credibility deposit. The 10K PYDE floor is a credible-commitment deposit, not the load-bearing economic defense. It ensures every validator has some skin in the game and gives the slashing mechanism something to slash. Combined with the operator cap, the lower bound on committed capital for a 43-Byzantine attack is ≥ 15 operators × 3 validators × 10K PYDE = 450K PYDE locked plus the legal and reputational exposure of 15 KYC'd entities. Modest in dollar terms; meaningful in coordination terms.
The honest framing
The single-number "you'd lose $N million in stake to attack" argument that other chains lead with does not apply here. Pyde's claim is different and stronger: the protocol is designed such that there is no profitable attack to fund. Stake economics back this up at the margin. Operator identity binding does the heavy lifting on Sybil specifically. The threshold-encryption property does the work of removing the attack value entirely.
This shifts the trust assumption from "stake is large enough to deter attack" to "operator-identity binding + slashing + structural MEV-resistance jointly make attack unprofitable and detectable." The second is a substantively different argument and worth being explicit about.
Genesis Sybil resistance
The initial 128-validator set is Foundation-curated at genesis (Phase 10 of the launch plan, recruited + validated across 3+ regions). This is a "trusted launch" assumption — not that the Foundation is trusted forever, but that the initial set is diverse and honest. After genesis, committee rotation and permissionless stake-based registration take over.
16.5 Eclipse Attacks
The attack
An adversary surrounds a single target validator with only-adversary peers. The target sees whatever the adversary wants them to see: fake proposals, faked votes, a fake chain. If the adversary can eclipse enough validators, they can force consensus on a fake state (though safety still holds under the 1/3 rule, liveness can be hurt).
The defense
- Peer diversity. The peer manager
(
crates/net/src/peer.rs) caps connections per/24subnet. An adversary would need to control IP addresses across many subnets, not just spin up lots of VMs on one provider. - Layered discovery (no DHT). Pyde explicitly chose not to use a Kademlia DHT (Chapter 12). Discovery is layered: hardcoded seeds, DNS, on-chain validator registry, PEX, local cache. This eliminates the DHT-poisoning eclipse vector — an attacker can't pollute a routing table that doesn't exist.
- FALCON peer authentication (§12.4). After the libp2p connection, peers run a FALCON-signed attestation that binds PeerId to a post-quantum identity. An adversary can't clone a validator's PeerId without their FALCON secret key.
- Sentry node pattern (Chapter 12). Committee validators are reachable only through trusted sentry proxies — their real IPs are not in the public peer set. Eclipsing a committee validator requires compromising the sentry layer, not just the public network.
- Validator-channel filtering. The vertex channel only accepts messages from peers whose attested FALCON pubkey is in the current committee. A non-validator eclipse peer can inject garbage on gossip topics but cannot fake vertex signatures.
What isn't defended (yet)
The current peer-scoring system is deliberately simple (reputation =
messages_received - 10 * invalid_messages). A more sophisticated
gossipsub score with per-topic weights, decay parameters, and gray-listing
is on the post-mainnet hardening list. The current model is enough for
the DDoS-shaped threats at mainnet scale; more sophisticated Eclipse
attacks against one specific validator would show up as anomalous peer
behavior that operators could see in their metrics.
16.6 DDoS Resistance
Connection-level
#![allow(unused)] fn main() { DEFAULT_RATE_LIMIT_PER_IP = 5 conns/sec DEFAULT_MAX_PEERS = 50 DEFAULT_MAX_INBOUND = 30 DEFAULT_MAX_OUTBOUND = 20 }
Per-IP rate limiter throttles new connections; per-subnet limit prevents
one network from hogging peer slots. An attacker flooding an RPC endpoint
bumps against conn_rate_limit_per_ip and saturates at 5 new connections
per second per source address.
Evidence-ingest rate limiting (task 014d)
A non-validator peer can submit evidence messages to validators that then verify them. Naive validators would FALCON-verify every evidence message at ~60 µs each — enough for a flood of invalid evidence to saturate CPU.
The fix: token-bucket rate limit on evidence messages, applied per-peer.
Repeat offenders are dropped after the first failed verification instead
of verifying indefinitely. Lives in crates/net/src/ddos.rs.
Per-channel message size limits
Each gossipsub channel has its own max message size:
| Channel | Max size |
|---|---|
| Vertices | 256 KB |
| Transactions | 128 KB |
| Batches | 4 MB |
| Sync | 16 MB |
| Evidence | 64 KB |
Oversized messages are rejected and the sender takes a reputation hit.
RPC ingress validation (task P7a-3)
Invalid transactions never enter the mempool. The ingress validator
(crates/node/src/rpc.rs::ingress_validate) checks chain_id, FALCON sig,
nonce window, balance, gas bounds, deadline, access-list duplicates, tx
size, calldata size — all before returning Ok or gossipping. Pollution is
isolated to the single ingress node.
Mempool per-sender caps
DEFAULT_MAX_TX_PER_WINDOW_PER_SENDER = 10 (per 1-sec window)
DEFAULT_MAX_CONCURRENT_PER_SENDER = 100 (concurrent txs in pool)
A single spammer cannot flood the mempool. If they try, their per-sender quota blocks further submissions until the window slides.
16.7 Front-Running and MEV
Covered in detail in Chapter 9. The short version:
- Optional threshold-encrypted mempool. Tx payload hidden from everyone until 85-of-128 Kyber shares combine. Users opt in per tx.
- Commit-before-reveal DAG ordering. The DAG anchor commit at round R+3 fixes the canonical order; decryption shares are released only at R+4. No actor has both "can read contents" and "can alter order" at any single round.
- Structural inclusion. No single proposer to censor; censoring a tx requires ≥ 44 colluding committee members.
- No tips. The wire format has no priority-fee field.
Each layer closes attacks the others cannot. Together, MEV is not discouraged — it is structurally unexpressible.
16.8 State Manipulation
The attack
A malicious vertex producer submits a wave-anchor candidate whose claimed post-state root doesn't actually match the result of executing the wave's transactions. Honest validators would incorrectly accept a bogus state.
The defense
Every honest validator executes each committed wave themselves and
FALCON-signs (wave_id, blake3_state_root, poseidon2_state_root). A
malicious vertex producer that claims a wrong root gets 0 honest
state-root sigs; the network cannot reach the 85-sig finality bar.
Two conflicting state claims can't both reach finality (same BFT argument: > 1/3 would have to equivocate). State root divergence is hard-halt detectable (Chapter 7) — the network stops automatically once two contradictory signed roots appear.
JMT Merkle proofs
For light clients that do not execute the wave, the JMT batched proof +
the signed blake3_state_root + the committee's FALCON signatures are
the authentication path. A light client verifies:
HardFinalityCertfor the wave is valid (≥ 85 FALCON sigs).- The JMT proof from
blake3_state_rootto the specific leaf is valid. - The leaf value is what the light client was querying.
The chain of authentication is end-to-end cryptographic. ZK light clients
(post-mainnet) can use the parallel poseidon2_state_root for SNARK-based
verification at much lower cost.
16.9 Quantum Attacks
Every primitive in the protocol is post-quantum:
- FALCON-512 signatures — NTRU lattice, not factoring.
- Kyber-768 / ML-KEM key exchange — lattice, not ECDH.
- Poseidon2 hashing — algebraic, not affected by quantum.
- Lattice VRF — inherits FALCON security.
- AES-256-GCM — symmetric, 128-bit post-quantum security under Grover's algorithm.
The weakest link is Poseidon2's 64-bit post-quantum collision resistance
(Grover halves the exponent). 64-bit collision resistance requires
2^64 quantum hash evaluations, which is far beyond any realistic near-
or mid-term quantum capability. If cryptanalytic advances tighten this,
a hash migration is a standard-shape protocol upgrade.
Pyde has no elliptic-curve crypto anywhere in the protocol. No secp256k1, no ed25519, no BLS12-381. The libp2p transport layer uses ed25519 for PeerId routing only; application-level authentication uses FALCON.
16.10 Smart Contract Safety
The default-safe properties Otigen the language provided are preserved in the WASM era. Mechanism changed; guarantees did not. See Chapter 5 §5.6 for the full attribute surface.
- No reentrancy by default. Every function is guarded by the WASM execution layer; opt out with the
reentrantattribute (language-native:#[pyde::reentrant]/@pyde.reentrant///pyde:reentrant/PYDE_REENTRANT). - Checked arithmetic. Encouraged by per-language SDK helper patterns; wrapping ops require explicit opt-in (e.g., Rust's
wrapping_addis explicitly named). - Typed storage. Declared in
otigen.toml[state]schema; the build tool emits type-safe accessors and the runtime enforces slot-hash uniqueness. - No
tx.origin. The host function ABI exposes onlycaller()(direct caller). The Solidity-style phishing vector is absent. - Access-list enforcement. Slot accesses against slots not declared in the contract's state schema fail at the host-function layer.
These defaults eliminate the most common smart-contract exploit classes at the toolchain + runtime level, not as library choices developers might forget.
The toolchain audit surface
The otigen developer toolchain — specifically its state binding generators and ABI extractor — is part of the audit surface. A codegen bug in a binding generator could emit accessor code that violates declared semantics. Mitigations:
- Unit tests per binding-generator output pattern. Each language target (Rust, AssemblyScript, Go, C/C++) has its own generator with its own test suite covering every accessor shape.
- Property tests for slot-hash determinism across languages — given the same
otigen.toml, all four generators must produce identical runtime slot_hash values for identical inputs. - External audit of the
otigentoolchain before mainnet. - Wasmtime as a trust-minimized dependency. The execution runtime itself is wasmtime, which inherits years of production fuzzing and Bytecode Alliance audit attention — we do not audit a VM we built ourselves.
16.11 WASM Execution Layer Safety
Pyde's execution layer is wasmtime (with Cranelift AOT). The trap surface is wasmtime's, augmented by host-function-specific traps Pyde injects through the ABI.
WASM-native traps
wasmtime traps when the executing module violates its sandbox or its fuel budget. The canonical trap conditions:
OutOfFuel IntegerOverflow IntegerDivisionByZero
MemoryOutOfBounds StackOverflow UndefinedElement
IndirectCallToNull BadSignature UnreachableCodeReached
TableOutOfBounds Interrupt (host-function traps)
Each trap is a clean revert: state writes roll back, gas is consumed up to the trap point (computed from fuel actually consumed), the transaction fails. There is no undefined behavior path. wasmtime's sandbox guarantees structural safety: no buffer overflows, no control-flow hijacks, no type confusion.
Pyde-specific traps via host functions
The host functions add another trap layer for Pyde-specific safety properties:
| Trap | When |
|---|---|
ReentrancyViolation | A cross_call re-enters a non-reentrant function |
AccessListViolation | A slot access targets a slot outside the declared state schema |
ViewFunctionStateModify | A state-modifying host call inside a view-attributed function |
NonPayableValueAttached | tx.value > 0 on a non-payable function |
ConstructorReentrant | An attempt to call a constructor-attributed function post-deploy |
GasTankExhausted | A sponsored function's contract gas tank ran out |
InsufficientBalance | transfer host call when sender balance is below amount |
ForbiddenImport | (deploy-time only) module imports a function outside the ABI allowlist |
Determinism enforcement
wasmtime is configured to reject any module that uses non-deterministic features. The config enforces (at module instantiation and at deploy validation):
cranelift_nan_canonicalization(true)— floating-point NaN bit patterns canonicalized identically across all validatorswasm_threads(false)— no threading (non-deterministic by definition)wasm_simd(false),wasm_relaxed_simd(false)— SIMD disabled until a deterministic-only subset is vettedwasm_reference_types(false),wasm_gc(false),wasm_function_references(false)— complexity surface gated until neededwasm_multi_memory(false),wasm_memory64(false)— explicit memory layout- No WASI imports
A deploy-time validator (crates/wasm-exec/src/validate.rs) re-checks the module's import section against the allowlist and rejects anything that would slip past wasmtime's instantiation check.
Trust-minimization of the runtime
We do not audit wasmtime itself — that work is done continuously by the Bytecode Alliance with years of production fuzzing under adversarial workloads. We pin a tagged wasmtime version per chain release, document the version in the protocol upgrade record, and require validators to upgrade in coordinated forks when we move it. This is a meaningfully smaller audit surface than maintaining a custom VM ourselves would have been (see The Pivot preface for the full reasoning).
16.12 Consensus-State Persistence
The risk. If a validator casts a vote, crashes before the vote is durable, and restarts with a different view, it can double-vote on restart — violating BFT safety.
The defense.
WriteOptions::set_sync(true)on every write to the consensus store (task 014a). A vote is not considered "cast" untilfsyncreturns.panic!+panic = "abort"on any persist failure (task 014b). The process terminates immediately. Continuing after a failed disk write is a BFT-unsafe operation; halting is the correct fail-safe.- Restart recovery reloads
seen_proposals,seen_votes,pending_evidencefrom the consensus store (task 003, 014c).
Microbenchmark (task 014f) confirmed the per-vertex-sig fsync cost is ~25.5 µs on Apple Silicon NVMe — ~39K writes/sec headroom against the ~150 ms round cadence (≥ 1000× margin).
Gradeful drain-and-shutdown on persist failure is a post-mainnet operational polish, not a launch blocker.
16.13 Replay Protection
Cross-chain replay
Every transaction includes chain_id in the canonical hash. A
transaction signed for mainnet cannot be replayed on a testnet; a
testnet tx cannot be submitted to mainnet. Chain IDs:
| Network | chain_id |
|---|---|
| Mainnet | 1 |
| Testnet | TBD |
| Devnet | 31337 |
The chain_id is always enforced; the dev_skip_signature flag only
disables signature verification for chain_id 31337 (devnet), and only
if the config explicitly allows it. On any chain_id other than 31337,
signatures are always required.
Same-chain replay
Each transaction has a nonce that must fit the sender's 16-slot bitmap window (Chapter 11). Once used, the bitmap bit stays set until the window slides past it. A replayed tx hits the bitmap and is rejected.
Multisig replay
Treasury multisig spends include the current MULTISIG_NONCE in the
signing bytes. After a spend, the nonce bumps, so the same signed bytes
cannot be replayed.
Emergency replay
EmergencyPause and EmergencyResume include the current
MULTISIG_NONCE in their signing context. A paused chain that auto-
expires cannot be re-paused by replaying the same signed payload.
16.14 Treasury Security
See Chapter 15 for the governance model. The treasury's on-chain protections:
- Multisig-only spend. No other transaction type drains the treasury account.
- Audit trail.
data_digest = hash(pip_file_contents)ties every spend to a published PIP. - Rotation.
RotateMultisigcan replace the signer set; no single signer is entrenched. - Writeback-clobber protection.
spend.target != tx.from,tx.to == 0x00. Prevents the post-execution pipeline from accidentally overwriting the spend. - Nonce-bound signatures. Each spend bumps
MULTISIG_NONCE; replays fail.
The multisig signer set is a trust assumption. The mitigation is scope: the signers can spend the treasury and rotate themselves; they cannot change consensus rules, supply, or fee distribution.
16.15 Operational Security
Aspects that are not cryptographic but matter at mainnet operation:
- Key management. Validator FALCON secret keys are kept in
hardware-backed storage where possible. Key rotation transactions
(
key_noncebump) exist for compromised-key recovery. - Sentry nodes. Validators typically expose a sentry node for P2P traffic and keep the validator process unreachable directly. This is a deployment concern, not protocol-enforced.
- Monitoring. Every node exposes Prometheus metrics; operators run alerting on consensus participation rate, block inclusion rate, and peer churn.
- Bug bounty. A permanent bug bounty program is part of the community allocation (Chapter 14). The Phase 7 testnet tier has its own bounty; the mainnet tier will be funded at launch.
- Incident response. Phase 10 of the mainnet plan specifies on-call rotation and incident response SOPs. The emergency-pause mechanism gives operational response a real lever during a live exploit.
16.16 Hardening Work In-Flight
Pre-mainnet hardening work tracked in the launch plan (chapter 19):
| Task | Status |
|---|---|
| Clippy/fmt/audit/deny in CI | Hardening track; shipping |
cargo-fuzz on wasm-exec / tx / consensus / RPC / otigen toolchain | 72+ h runs |
| Property tests on pipeline + tokenomics | Initial properties shipped; expanding |
| Witness 1 MB bound validation | Shipped |
Separate MAX_CALLDATA cap | Shipped |
unsafe block invariant docs | Being documented |
unwrap() triage on untrusted paths | Ongoing |
| ml-kem 0.3.0-rc -> stable upgrade | Post-standards-release |
| Persistent receipt store (archive mode) | Post-mainnet |
| Signed-commitment mandatory inclusion | Post-mainnet (Ch 9) |
| Pedersen / KZG commitments for PSS | Post-mainnet |
| Algebraic batch FALCON verify | Post-mainnet |
The honest shape at mainnet: a small, audited, heavily-tested core with a well-scoped set of known future hardening items.
16.17 External Audits
The launch plan schedules five independent external audits before mainnet:
| Audit scope |
|---|
| Consensus layer (Mysticeti DAG, anchor selection, finality, slashing) |
Execution layer (Pyde's host-function ABI, the wasm-exec integration, fuel-to-gas mapping, Block-STM scheduler + MVCC layer + determinism contract) |
Crypto implementations (FALCON, Kyber, Blake3, Poseidon2, threshold, PSS) — in pyde-crypto polyrepo |
| Networking layer (libp2p config, gossipsub, layered discovery, sentry pattern, DDoS) |
otigen developer toolchain (binding generators, ABI extraction, deploy flow, wallet) |
Note: wasmtime itself is not separately audited — it is a vetted production dependency from the Bytecode Alliance. The Pyde audit focuses on the integration surface (host functions, fuel mapping, validation gate, module cache) and on the toolchain that emits the WASM modules.
Critical + high findings are remediated before mainnet; audit remediations themselves are re-audited. Penetration testing (P2P flooding, RPC DoS, eclipse simulations) runs in parallel.
Summary
| Property / defense | Status at mainnet |
|---|---|
BFT safety f < n/3 | Shipped |
Liveness 85/128 honest + online (2f+1) | Shipped |
| Weak-subjectivity checkpoints | Shipped |
| FALCON peer authentication | Shipped |
| Validator-channel filtering | Shipped |
| Evidence-ingest rate limit | Shipped |
| Per-sender mempool rate limit | Shipped |
| RPC ingress validation | Shipped |
chain_id replay protection | Shipped |
| Multisig-only treasury drain | Shipped |
panic = "abort" on persist failure | Shipped |
| Set-sync(true) consensus writes | Shipped |
| WASM sandbox (wasmtime, production-vetted) | Inherited from wasmtime |
| Deterministic-feature-subset enforcement | Shipped (deploy-time validator) |
| Host-function-level safety traps | Designed; implementation in flight |
| Reentrancy guard (default-on) | Designed; runtime in flight |
| 1 MB witness size cap | Shipped |
| Separate MAX_CALLDATA cap | Shipped |
| Signed mempool commitments | Post-mainnet |
| Pedersen / KZG PSS commitments | Post-mainnet |
| Algebraic batch FALCON verify | Post-mainnet |
| Archive-node receipt store | Post-mainnet |
| External audits (5 specialists) | Pre-mainnet, Phase 8 |
The next chapter covers developer tools: the otigen developer toolchain, the pyde node binary, the Rust and TypeScript SDKs, the WASM crypto bindings, and the JSON-RPC surface.
Chapter 10: Gas and Fee Model
Pyde meters every operation in gas. The economic model on top of gas is EIP-1559 with 4× elastic blocks, deterministic 70/20/10 fee distribution, and no priority fees. There is no tip field, no builder/proposer separation, no bidding war for inclusion order.
This chapter covers the full model: gas costs per opcode, the EIP-1559 base fee math, elastic block sizing, the 70/20/10 split, sponsored transactions through gas tanks, and the calldata/tx size limits.
10.1 Gas Accounting
Pyde uses wasmtime's fuel mechanism for gas metering. At node startup, the engine establishes a deterministic mapping from gas units (the chain-level metering unit) to wasmtime fuel units. Every WebAssembly instruction consumes a configurable amount of fuel; host function calls also consume fuel manually, charged by the host based on operation cost (sstore is heavier than add, for example).
When fuel reaches zero, wasmtime traps the execution with an out-of-fuel error. The transaction reverts; the sender pays gas for all the work done up to the trap point. There is no refund.
#![allow(unused)] fn main() { struct ExecContext { gas_limit: u64, // set by the transaction gas_used: u64, // computed from fuel consumed during execution } }
Charging model: validate up front, deduct after execution
Step 1 — Ingress validation (at RPC):
Check sender.balance ≥ gas_limit × base_fee + value_attached.
If insufficient: REJECT before mempool admission.
Step 2 — Mempool admission + propagation:
No balance changes. Tx flows through workers, batches, vertices.
Step 3 — Execution (at wave commit):
Re-check balance (sender may have spent in prior txs of this wave).
Execute via wasmtime, tracking consumed_fuel.
On completion (success OR trap):
gas_used = fuel_to_gas(consumed_fuel)
charge = gas_used × base_fee
sender.balance -= charge + (value_attached if execution succeeded)
Step 4 — Fee distribution (always 70/20/10):
burn += charge × 0.70
reward_pool += charge × 0.20
treasury += charge × 0.10
No gas refunds in v1
Pyde v1 ships with zero gas refunds. gas_used is what the user pays, always. The sdelete host function is a regular metered operation; it has a lower gas cost than sstore (clearing a slot is less work than writing), but there is no refund applied on top.
The reasoning:
-
Ethereum had to roll back gas refunds via EIP-3529 after gas-token attacks (CHI, GST2) abused refunds to manipulate gas markets at scale. The refund mechanism turned out to be an attack surface, not a feature.
-
Pyde handles state cleanup at the engine layer via PIP-4's write-back cache + state pruning policy, not via user incentives. Storage doesn't accumulate unbounded regardless of whether users explicitly delete. The financial incentive is unnecessary.
-
Simpler accounting. No refund-capping rules, no two-step charge-then-refund logic, no edge cases. Receipts carry one number —
gas_used— and that's the charge.
Why fuel, not opcode counting
Fuel is built into wasmtime's Cranelift backend. Every basic block is instrumented to decrement a fuel counter; when the counter goes negative, execution traps. The instrumentation is efficient enough not to dominate execution time.
Implementing custom opcode-counting on top of wasmtime would be slower and add maintenance burden for no functional gain. The chain-side gas table maps WASM instruction categories and individual host functions to fuel costs; the engine consumes that table at startup and configures wasmtime accordingly.
Why a single dimension
Earlier drafts of this book described a two-dimensional gas model
(exec_cost + prove_cost) intended to price both CPU work and ZK proving
work separately. With ZK proving deferred to post-mainnet, the
proving-cost dimension does not exist at launch and the two-dimensional
model collapses into a single number — the chain-level gas total derived from wasmtime fuel consumption.
Should ZK proving land later, the second dimension can be re-introduced as a separate counter without changing the wire format (transactions already carry only gas_limit).
10.2 EIP-1559 Base Fee
Pyde's base fee adjusts every block by up to 12.5% in either direction based on whether the previous block exceeded or fell below the gas target.
Constants (crates/tx/src/fee.rs)
| Constant | Value | Meaning |
|---|---|---|
GAS_TARGET | 400,000,000 | 50% of the elastic ceiling |
GAS_CEILING | 1,600,000,000 | 4× target — hard block ceiling |
GENESIS_BASE_FEE | 50,000,000,000 quanta | Initial value at genesis |
MIN_BASE_FEE | 1 | Floor — cannot drop to zero |
ADJUSTMENT_DIVISOR | 8 | 1/8 = 12.5% max change per block |
Adjustment formula
#![allow(unused)] fn main() { fn adjust_base_fee(parent_base_fee: u128, parent_gas_used: u64) -> u128 { if parent_gas_used == GAS_TARGET { parent_base_fee } else if parent_gas_used > GAS_TARGET { let delta = parent_gas_used - GAS_TARGET; let bump = parent_base_fee * delta as u128 / GAS_TARGET as u128 / 8; parent_base_fee + bump.max(1) } else { let delta = GAS_TARGET - parent_gas_used; let drop = parent_base_fee * delta as u128 / GAS_TARGET as u128 / 8; (parent_base_fee.saturating_sub(drop)).max(MIN_BASE_FEE) } } }
Properties:
- Proportional adjustment. The change scales with how far the block deviated from target. A block at 75% target produces a smaller bump than one at 100% target.
- Capped at ±12.5% per block. No oracle, no governance vote.
- Bounded below by
MIN_BASE_FEE. Cannot reach zero. - Minimum increase of 1 quanta. Even at very low fees, a busy block bumps the fee at least one quanta.
Convergence at ~500 ms commits
Mysticeti DAG produces a commit every ~500 ms median (Chapter 6).
Each commit is the unit at which the base fee is recomputed (block and
commit are interchangeable here — Pyde collapses both concepts
since the DAG commits at per-commit granularity).
| Scenario | Time to 2× the fee |
|---|---|
| Sustained 100% full commits | ~11 commits (~5.5 s) |
| Sustained 4× full (max) | ~6 commits (~3 s) |
| Sustained empty | half-life ~5 commits (~2.5 s) |
Equilibrium under fluctuating demand sits around 50% of the gas target.
10.3 Elastic Blocks
Pyde blocks have two gas limits:
| Limit | Value (gas) | Role |
|---|---|---|
| Target | 400,000,000 | "Normal" block fullness |
| Hard ceiling (4×) | 1,600,000,000 | Cannot exceed even under congestion |
Block builders can pack up to 4 × GAS_TARGET = 1.6B gas into a single
block. When they exceed the target, the base fee for the next block rises
proportionally.
Gas usage during a congestion spike:
4× ┤ ......................... hard ceiling
│
3× ┤ +-+
│ / \
2× ┤ +---+ +---+
│ / \
target┤----+ +---+---- target line
│ / \
1× ┤ / +-...
│
+---------------------------------------> blocks
spike decay
base fee rises ~2x then settles
Why 4× and not higher
- Validator memory. A 4× block has up to 4× more transactions to buffer, decrypt, and execute. The per-validator memory ceiling caps how high this can safely go on commodity hardware.
- Decryption + voting timing. Threshold decryption shares for a 4× block take longer to combine; the commit timing budget assumes the worst case fits.
- State growth. Larger blocks drive faster state growth. The 4× ceiling bounds worst-case growth by the same factor.
Gas-bound throughput ceiling
At 2 commits/sec (~500 ms commit), GAS_TARGET = 400M, GAS_CEILING = 1.6B:
| Workload | Gas/tx | Relative gas-bound ceiling | Realistic v1 (committee-bound) |
|---|---|---|---|
| Simple transfer | 21,000 | Highest | awaiting harness |
| Token transfer (ERC-20) | 65,000 | Moderate | awaiting harness |
| DEX swap | 200,000 | Lowest | awaiting harness |
Honest v1 numbers. The gas-bound ceiling above is a mechanical cap — block gas budget ÷ gas-per-tx — assuming committee hardware fully saturates execution. In practice, the v1 honest throughput target (to be established by the multi-region performance harness) is set on commodity committee hardware (500 Mbps NIC, 32-core, 64 GB). Higher numbers require larger NICs and more cores; see Chapter 19 for the launch-strategy capacity table.
Real numbers depend on workload composition. The performance harness (companion/PERFORMANCE_HARNESS.md) is the only valid source of TPS claims — publish only what the harness measures under sustained, production-realistic conditions, never lab extrapolations or microbenchmark peaks. The headline number is never the theoretical max.
10.4 No Tips, No Priority Fees
Pyde's transaction format has no priority-fee field. Every transaction pays exactly:
fee = gas_used * base_fee
There is no bidding, no auction, no out-of-protocol payment to any committee validator. The MEV-protection consequences are spelled out in Chapter 9; the gas-economics consequences are:
- Predictable fees. Wallets can quote a single number, not a range.
- No fee market gaming. No need for fee-estimation oracles or multi-priority queues.
- Simpler accounting. The fee distribution is a single division, not a base-vs-tip split.
How does ordering happen, then?
Under the Mysticeti DAG, ordering is a deterministic function of the committed subdag — vertices are produced independently each round, the anchor commit selects a canonical traversal, and transactions emerge in a fixed canonical order. No actor chooses positions; the order is structural (Chapters 6 and 9).
For sequential nonce dependencies, the protocol uses the 16-slot nonce
bitmap window (Chapter 11) — a sender can submit txs n, n+1, n+2
out of order; gaps are tolerated up to the window size.
Legitimate urgency
Use cases that need fast inclusion (liquidations, bridges, time-sensitive trades) have two routes:
- Pre-fund a paymaster's gas tank (sponsored tx — see §10.7) so the user doesn't bottleneck on liquidity.
- Use the deadline field to expire stale txs that were not included quickly, freeing the nonce slot for a fresh attempt.
Neither route bribes anyone for ordering.
10.5 Fee Distribution: 70 / 20 / 10
Every fee splits deterministically:
| Recipient | Share | Where it goes |
|---|---|---|
| Burn | 70% | Increments the on-chain TOTAL_BURNED counter |
| Reward pool | 20% | Pooled across all staked validators (active committee + validators awaiting selection), distributed each epoch by stake × uptime via lazy accrual |
| Treasury | 10% | Credited to the treasury account |
Note: in the pre-pivot HotStuff design the 20% went directly to the slot proposer. Under the DAG there is no single proposer, so the validator share goes to an epoch reward pool indexed by stake and uptime. See Chapter 14 for the per-validator yield math.
Implemented as distribute_fee in crates/tx/src/execution.rs:
#![allow(unused)] fn main() { pub fn distribute_fee(effective_gas: u64, base_fee: u128) -> FeeDistribution { let total_fee = effective_gas as u128 * base_fee; let burned = total_fee * 70 / 100; let reward_pool = total_fee * 20 / 100; let treasury = total_fee - burned - reward_pool; // remainder catches rounding FeeDistribution { burned, reward_pool, treasury } } }
The remainder-to-treasury pattern catches rounding dust so no quanta are lost.
Why not 100% burn?
A 100% burn (Ethereum's EIP-1559 model for the base fee) means validators get nothing from fees and depend entirely on inflation rewards. This works when inflation is generous, but it makes the security budget brittle: as inflation decreases, validator economics become fully dependent on tip volume, which Pyde doesn't have.
The 20% reward-pool share compensates the full staked validator set (both active-committee and validators awaiting selection, per stake × uptime) and ties their compensation to network usage in addition to inflation. Under the DAG there is no single proposer to credit, so the share is pooled and distributed at epoch end. The 10% treasury share funds protocol work via PIP-driven multisig spends (Chapter 15).
Why no prover share?
Earlier drafts of this book had a 70 / 20 / 10 split where the 10% went to provers. Without provers at mainnet, that 10% goes to the treasury. The on-chain math is the same; only the recipient changed.
If ZK proving lands in a future hardfork, the split can be adjusted by governance (a PIP + on-chain multisig action). Until then the treasury gets the 10%.
10.6 Fee Calculation Examples
Simple transfer (21,000 gas)
At GENESIS_BASE_FEE = 50,000,000,000 quanta:
fee = 21,000 * 50,000,000,000
= 1,050,000,000,000,000 quanta
= 1,050,000 micro-PYDE
= 1.05 milli-PYDE
= 0.00105 PYDE
Distribution:
Burn: 735,000,000,000,000 quanta (~0.000735 PYDE)
Validator: 210,000,000,000,000 quanta (~0.000210 PYDE)
Treasury: 105,000,000,000,000 quanta (~0.000105 PYDE)
High-congestion scenario
If sustained demand has driven the base fee 3.5× higher:
base_fee = 175,000,000,000 quanta
fee = 21,000 * 175,000,000,000 = 3,675,000,000,000,000 quanta = 0.003675 PYDE
Burn: 2,572,500 micro-PYDE
Validator: 735,000 micro-PYDE
Treasury: 367,500 micro-PYDE
Low-demand scenario
If sustained empty blocks have driven the base fee to half normal:
base_fee = 25,000,000,000 quanta
fee = 21,000 * 25,000,000,000 = 525,000,000,000,000 quanta = 0.000525 PYDE
The base fee keeps adjusting until the market clears — congestion makes it expensive to spam, low usage makes inclusion cheap.
10.7 Sponsored Transactions
A user with no PYDE balance can still transact if a contract or paymaster account pays the gas. Two mechanisms exist.
Gas tanks
Every account has a gas_tank: u128 field (see Chapter 4 / 11). It's a
balance separate from the account's spendable balance, dedicated to paying
gas on behalf of users.
Anyone can deposit to any account's gas tank:
deposit_gas_tank(target, amount)
Only the account owner can withdraw:
withdraw_gas_tank(target, amount, recipient)
To use a gas tank, a transaction sets:
tx.fee_payer = FeePayer::GasTank
The engine looks up the target contract's gas_tank, debits the fee from
there, and credits the receiver as usual. If the gas tank is empty, the tx
reverts (the sender did not pay).
Paymaster pattern
For more complex sponsorship (eligibility checks, per-user limits), a paymaster contract sits between the user and the target:
tx.fee_payer = FeePayer::Paymaster(paymaster_address)
The engine calls the paymaster's validate_sponsorship(user, target, calldata) -> bool function (gas-bounded — see below). If it returns true,
gas is debited from the paymaster's gas tank.
+----------+ +------------------+ +-----------------+
| User |----->| Paymaster |---->| Target |
| (no $) | | - eligibility | | Contract |
+----------+ | - rate limits | +-----------------+
| - gas tank pays |
+------------------+
Validation gas limit
To stop a paymaster from running an expensive validation function as a DoS
vector, the paymaster's validate_sponsorship has a hard gas cap of
100,000 gas. If validation exceeds that, the tx is rejected. This
prevents an adversarial paymaster from making mempool inclusion expensive
for relays.
Use cases
| Use case | Mechanism |
|---|---|
| Free-to-play games | Game contract's gas tank pays for player moves |
| DeFi onboarding | Protocol pays for first N swaps per user |
| Corporate dApps | Company paymaster covers employee transactions |
| Airdrop claims | Airdrop contract sponsors claim transactions |
| Governance voting | DAO pays gas for governance participation |
10.8 Gas Costs for Common Operations
The full WASM-instruction and host-function gas table is published in the Host Function ABI specification. The headline numbers for the operations that dominate real-world gas usage:
Storage
| Operation | Host function | Gas |
|---|---|---|
| Storage read | sload | 100 (warm) |
| Storage write | sstore | 200 (warm) |
| Storage delete | sdelete | 150 (no refund; cheaper than sstore) |
Crypto
| Operation | Host function | Gas |
|---|---|---|
| Poseidon2 hash | poseidon2 | 1,000 + 6 per 32B chunk |
| Blake3 hash | blake3 | 100 + 1 per 32B chunk |
| Keccak256 hash | keccak256 | 200 + 3 per 32B chunk |
| FALCON-512 verification | falcon_verify | 20,000 |
| Merkle path verification | host fn | 5,000 |
Cross-contract
| Operation | Host function | Gas |
|---|---|---|
| External call | cross_call | 2,500 + callee work |
| Contract deployment | system tx | 32,000 + init code |
Events
| Operation | Host function | Gas |
|---|---|---|
| Emit event | emit_event | 375 + 8 per byte |
WASM execution (per-instruction baseline)
| Category | Fuel cost |
|---|---|
| Arithmetic instructions | 1-3 fuel per op |
| Memory load/store | 5 fuel per op |
| Control flow | 1-2 fuel per op |
| Memory grow | 200 fuel per 64KB page (first touch) |
The build-time state binding generator (see Chapter 5) emits efficient access patterns; for example, a single map lookup expands to one host-function call rather than multiple. The wasmtime-AOT pass then compiles the resulting WASM to native code for execution.
10.9 Validation Limits
The transaction validator
(crates/tx/src/validation.rs) enforces these limits at RPC ingress:
| Limit | Value | Constant |
|---|---|---|
| Min gas limit | 21,000 | MIN_GAS_LIMIT |
| Max gas per block | 1.6B | BLOCK_GAS_MAX |
| Max tx size | 128 KB | MAX_TX_SIZE |
| Max calldata size | 64 KB | MAX_CALLDATA |
MAX_CALLDATA is a separate cap from MAX_TX_SIZE (per the audit
recommendation — task 055 in the mainnet plan). The split prevents an
attacker from building a tx whose calldata fills the entire 128 KB tx
budget and starves the rest of the encoded fields.
A transaction that fails any of these checks is rejected at the RPC node and never enters the mempool — pollution is constrained to that single ingress node.
10.10 Fee Estimation API
pyde_estimateGas runs the transaction in simulation against the current
state and returns the predicted gas consumption.
> pyde_estimateGas
> {
> "from": "0xpyde1abc...",
> "to": "0xpyde1def...",
> "data": "0x...",
> "value": "0x0"
> }
< {
< "gas_estimate": 45200,
< "base_fee": "0x2D79883D2000",
< "estimated_fee": "2260000000000000"
< }
Wallets typically multiply the estimate by ~1.10 to absorb state changes between estimation and inclusion. Because base fee can move at most ±12.5% per block, the inclusion-time fee is bounded relative to the estimation-time fee.
pyde_call runs read-only simulation without state mutation;
pyde_createAccessList produces the access list that should accompany the
transaction. Wallets typically chain these calls automatically:
createAccessList → estimateGas → submit signed tx with the resulting
access list.
10.11 Comparison
| Feature | Ethereum (EIP-1559) | Pyde |
|---|---|---|
| Gas dimensions | 1 | 1 |
| Base fee mechanism | Algorithmic (EIP-1559) | Algorithmic (EIP-1559) |
| Max base-fee change/block | ±12.5% | ±12.5% |
| Priority fee / tip | Yes | No |
| Block elasticity | 2× (15M target / 30M max) | 4× (400M target / 1.6B max) |
| Fee burn | 100% of base fee | 70% of total fee |
| Validator share | Tips only | 20% of total fee (no tip) |
| Treasury share | None | 10% of total fee |
| Native account abstraction | No (ERC-4337 add-on) | Yes (gas tanks + paymaster) |
| Storage rent | None | None (gas pays for the SSTORE) |
| MEV bribery resistance | None (tip-based ordering) | Structural (no tip; encrypted pool) |
10.12 Implementation Notes
Integer arithmetic
All fee calculations use integer arithmetic to avoid floating-point
non-determinism. Quanta are u128 (1 PYDE = 10^9 quanta — note this is
not Ethereum's 10^18 wei scale).
Overflow protection
compute_fee() uses checked_mul to detect overflow. Realistic inputs
(gas_used in millions, base_fee in billions of quanta) fit comfortably
in u128 (max product ≈ 2^60 * 2^40 = 2^100, well below 2^128). The
overflow check guards against pathological encodings.
Base fee in the commit header
Pyde's commit header is the equivalent of Ethereum's block header for fee-market purposes — each commit carries the base fee for transactions executed in that commit:
#![allow(unused)] fn main() { struct CommitHeader { // ... base_fee: u128, // base fee for txs in THIS commit gas_used: u64, // total gas consumed by this commit's txs gas_target: u64, // = GAS_TARGET (always 400M) gas_limit: u64, // = GAS_CEILING (always 1.6B) } }
(The web3-compatibility RPC methods pyde_getBlockByNumber /
pyde_getBlockByHash return a representation of this header, since
external tooling expects "block" terminology.)
The base fee for block N+1 is computed from block N's header by
adjust_base_fee() — every honest node arrives at the same value.
Summary
| Property | Value |
|---|---|
| Gas dimensions | 1 (single counter) |
| Base fee mechanism | EIP-1559, ±12.5% per block adjustment |
| Genesis base fee | 50,000,000,000 quanta |
| Gas target | 400,000,000 (50% of ceiling) |
| Gas ceiling | 1,600,000,000 (4× target — elastic max) |
| Priority fee / tip | None |
| Fee distribution | 70% burn / 20% reward pool / 10% treasury |
| Sponsored transactions | Native (gas_tank field + paymaster pattern) |
| Validation gas cap (paymaster) | 100,000 |
| Max tx size | 128 KB (MAX_TX_SIZE) |
| Max calldata size | 64 KB (MAX_CALLDATA) |
| Min gas limit | 21,000 |
| Storage rent | None |
The next chapter covers the account model the fee model sits on top of — addresses, the nonce window, multisig, and batch transactions.
Chapter 14: Tokenomics
PYDE is the network's native token. It pays for gas, secures consensus through validator staking, and funds protocol work via the treasury. This chapter covers the on-chain mechanics: supply, the inflation schedule, the fee distribution, validator economics, the vesting + airdrop machinery that ships at genesis, and the treasury that funds ongoing protocol work.
Numbers are taken from the actual code constants in crates/tx/src/fee.rs,
crates/slashing/src/lib.rs, and crates/consensus/src/validator.rs — not
from aspirational projections. Where a parameter is set at genesis (as
opposed to hard-coded), the chapter says so.
14.1 Denomination
PYDE has 9 decimals: 1 PYDE = 1,000,000,000 quanta (10^9).
1 quanta = 0.000000001 PYDE
1 micro-PYDE = 1,000 quanta (10^-6 PYDE)
1 milli-PYDE = 1,000,000 quanta (10^-3 PYDE)
1 PYDE = 1,000,000,000 quanta (10^9)
1 kilo-PYDE = 10^12 quanta (10^3 PYDE)
1 mega-PYDE = 10^15 quanta (10^6 PYDE)
All on-chain balances are stored as unsigned 128-bit integers in quanta. This easily covers the genesis supply of 1B PYDE (= 10^18 quanta) without overflow risk, and provides enough precision for micro-transactions.
(Note: Pyde's denomination is not Ethereum's 10^18 wei scale. SDKs expose the correct conversion automatically.)
14.2 Genesis Supply
Genesis total supply: 1,000,000,000 PYDE (1 billion).
#![allow(unused)] fn main() { pub const GENESIS_SUPPLY: u128 = 1_000_000_000 * 1_000_000_000; // = 10^18 quanta }
(crates/tx/src/fee.rs:84)
This is the entire on-chain PYDE in existence at block 0. From block 1 onward, new PYDE enters circulation only via the inflation schedule; no other minting path exists.
Distribution
The genesis allocation is set in the genesis configuration TOML; the on-chain machinery enforces:
- Per-bucket caps — the genesis builder rejects allocations that exceed the per-category caps to prevent oversupply.
- Vesting schedules — most non-validator allocations are subject to on-chain vesting (see §14.6).
- Validator subsidy stream — a portion of the genesis pool is reserved for validator subsidy that streams over a fixed window (§14.4).
- Airdrop pool — genesis seeds an airdrop account with the expected total; claims draw against it; the residual sweeps to the treasury after the deadline.
The exact percentages between buckets (treasury, team vesting, ecosystem, validator subsidy, airdrop) are governance-set parameters in the genesis file rather than protocol constants. The launch genesis is finalized by the Foundation in coordination with the validator set during the mainnet genesis ceremony (Phase 10 of the launch plan).
No supply cap
PYDE has no hard cap. The supply grows by a decreasing inflation rate and shrinks via the 70% fee burn. At target throughput the burn exceeds inflation and the network is net deflationary; at low throughput inflation dominates. The equilibrium depends on usage.
14.3 Inflation Schedule
The inflation rate decreases on a year-by-year schedule:
#![allow(unused)] fn main() { pub const INFLATION_BPS: [u16; 4] = [ 500, // year 1: 5.0% 300, // year 2: 3.0% 200, // year 3: 2.0% 100, // year 4+: 1.0% (terminal) ]; }
(crates/tx/src/fee.rs:92-98, expressed in basis points.)
Year Annual rate
---- -----------
1 5.0%
2 3.0%
3 2.0%
4+ 1.0% (terminal — never decreases further)
The 1% terminal floor exists so validators always have a baseline reward stream regardless of fee volume. At target throughput, fee burn easily exceeds 1% inflation; at lean throughput, inflation keeps validator economics viable.
Per-wave inflation reward
waves_per_year = 63_113_904 (2 commits/sec * 86400 s/day * 365.25 days)
reward_per_wave = GENESIS_SUPPLY * inflation_rate_bps / (10_000 * waves_per_year)
At year 1 (5%):
reward_per_wave = 10^18 quanta * 500 / (10_000 * 63_113_904)
≈ 792,202,572 quanta
≈ 0.792 PYDE per wave
At year 4+ (1%):
reward_per_wave ≈ 158,440,514 quanta ≈ 0.158 PYDE per wave
This per-wave reward credits the reward pool and the treasury at the shares specified by the on-chain reward distribution (see §14.4).
Why a decreasing schedule
- High initial inflation bootstraps validator participation before fee volume exists.
- Decreasing schedule rewards token holders as the network matures — early validators were taking risk that later operators don't.
- Terminal 1% stays low enough that ordinary fee burn at any meaningful usage produces net deflation.
14.4 Fee Distribution: 70 / 20 / 10
Every transaction fee splits deterministically (Chapter 10):
#![allow(unused)] fn main() { pub const FEE_BURN_PCT: u64 = 70; // burned (deflationary) pub const FEE_REWARD_POOL_PCT: u64 = 20; // distributed to stakers pub const FEE_TREASURY_PCT: u64 = 10; // treasury account }
(crates/tx/src/execution.rs:17-20)
The distribute_fee function:
#![allow(unused)] fn main() { pub fn distribute_fee(effective_gas: u64, base_fee: u128) -> FeeDistribution { let total_fee = effective_gas as u128 * base_fee; let burned = total_fee * 70 / 100; let reward_pool = total_fee * 20 / 100; let treasury = total_fee - burned - reward_pool; // remainder catches dust FeeDistribution { burned, reward_pool, treasury } } }
The remainder-to-treasury pattern means rounding dust never disappears.
Where each share goes
- Burn — increments the on-chain
TOTAL_BURNEDcounter under discriminator0x13. Permanently removes PYDE from circulation. - Reward pool — credited to the epoch reward pool account, distributed at epoch end to all staked validators (committee + non-committee) proportional to stake × uptime. Under the DAG there is no single proposer to credit; the pool model spreads rewards across the entire staked validator set.
- Treasury — credited to the treasury account at
Poseidon2("pyde-treasury"). Spent throughMultisigTx(Chapter 15).
Why 70% burn
- High burn pressure. At sustained moderate usage with realistic fee loads, the annual burn exceeds the annual mint within a few years — net deflation.
- MEV resistance. A would-be MEV searcher who used Pyde for extraction would burn 70% of the captured value. Combined with the encrypted-mempool protections (Chapter 9), this further dis-incentivizes attempts.
- Validator share is meaningful but not dominant. 20% pool share is enough to reward staking without making validators primarily fee-driven.
Net inflation analysis
Net inflation = (mint per year) − (burn per year). Illustrative figures at a representative base-fee assumption:
| Avg TPS | Annual fee burn | Year-1 mint (5%) | Net change |
|---|---|---|---|
| 500 | ~5.6M PYDE | 50M | +44.4M (inflationary) |
| 5,000 | ~28M PYDE | 50M | +22M (inflationary) |
| 10,000 | ~45M PYDE | 50M | +5M (near-neutral) |
| 20,000 | ~70M PYDE | 50M | -20M (deflationary) |
| 30,000 | ~105M PYDE | 50M | -55M (strong deflation) |
At sustained moderate usage, the network is near-neutral to deflationary in year 1. At the 1% terminal inflation rate (year 4+), even very low TPS produces net deflation.
14.5 Validator Economics
Single-tier staking
#![allow(unused)] fn main() { pub const MIN_VALIDATOR_STAKE: u128 = 10_000_000_000_000; // 10,000 PYDE }
| Role | Min stake | Committee role | Earns |
|---|---|---|---|
| Validator | 10,000 PYDE | Eligible — uniformly-random selection each epoch picks 128 of the eligible pool | Reward pool share (stake × uptime) + inflation share. When selected to the committee: additional activity-weighted share |
| RPC node | — | None | Off-chain RPC fees only |
Single pool, no tiers. Every validator meeting the 10K PYDE minimum is in the same pool. At each epoch boundary, uniform-random selection picks 128 from the pool to form the active committee for that epoch (see Chapter 6 §7). There is no "committee tier" vs. "non-committee tier" — just one validator role, with committee duty rotating per epoch.
Equal voting in committee. All 128 committee members have equal vote weight regardless of stake. To get additional selection probability, a wealthy staker must register multiple distinct validators with separate FALCON keys and operator identities — and each faces independent slashing exposure plus the per-operator cap (see below).
Why 10K, not higher. Pyde's MEV-extraction attack value is structurally near-zero (threshold encryption + commit-before-reveal ordering eliminate the profit motive that drives Ethereum-scale stake floors). With the attack-incentive removed, stake serves as a credible-commitment deposit against slashable misbehavior rather than as the load-bearing economic defense. Pyde's Sybil resistance is layered (operator-identity cap + slashing + threshold encryption + state-root divergence detection) — see Chapter 16 §16.4 for the full security argument.
The 10K floor matches the spirit of Ethereum's "Lean Consensus" direction (reducing 32 ETH → 4 ETH as fast finality reduces reversibility-window risk) and keeps the modest-hardware-decentralization promise intact: at realistic launch valuations, the bond is accessible without being trivial.
Anti-Sybil: operator-identity cap. Maximum 3 validators per operator identity. An attacker pursuing a Byzantine fork needs 43 committee slots, which translates to ≥ 15 distinct KYC'd operator identities under the cap — meaningfully harder to manufacture than capital alone.
Income sources
A validator's gross income per year:
- Inflation share. A portion of the per-block inflation reward, paid
to the epoch reward pool. Distributed across staked validators
(committee + non-committee) proportional to stake × uptime —
discriminator
0x15tracks the active stake-weighted total used as the denominator. - Fee revenue. 20% of every fee in every committed wave flows to the same epoch reward pool, distributed by the same stake × uptime rule (there is no single proposer in the DAG to credit).
Lazy reward accrual
Rewards do not get pushed to the validator on every block — that would
mean N writes per block. Instead, a global per-stake accumulator
(REWARDS_PER_STAKE_UNIT at discriminator 0x14) tracks the cumulative
yield per unit of staked PYDE × uptime:
On each block:
rewards_per_stake_unit += per_block_reward / total_active_stake_weighted_by_uptime
On ClaimReward (tx type 6):
owed = (current_accumulator - validator.last_claimed_at) * validator.stake * validator.uptime_share
pay owed
validator.last_claimed_at = current_accumulator
ClaimReward is only valid for Active (status 0x00) and Unbonding
(status 0x01) validators; Exited (status 0x02) validators are
explicitly rejected to prevent post-exit accrual leakage.
Validator status lifecycle
#![allow(unused)] fn main() { enum Status { Active = 0x00, Unbonding = 0x01, Exited = 0x02, } }
Transitions:
register -> Active
StakeWithdraw -> Unbonding (30-day countdown)
unbond expires -> Exited (stake returned, removed from pool)
slashed (forced) -> Exited (stake reduced or zero)
Unbonding period
#![allow(unused)] fn main() { pub const UNBONDING_PERIOD_DAYS: u64 = 30; // wall-clock, independent of consensus cadence }
(crates/consensus/src/validator.rs)
A validator who initiates StakeWithdraw (tx type 4) cannot reclaim their
stake until 30 days have passed. The period must exceed the
21-day safety-evidence freshness window so attackers cannot withdraw before
their offense becomes provable.
During the unbonding window:
- Status is
Unbonding. - Stake is locked.
- Validator no longer signs (removed from active committee).
- Pending rewards continue to accrue and can be claimed via
ClaimReward. - Slashing for past offenses still applies — the unbonding window exists precisely so post-exit evidence can still penalize.
After 30 days, an explicit follow-up sweeps the unbonded stake back to
the validator's spendable balance and marks them Exited.
Slashing
Reused from Chapter 6 and companion/SLASHING.md. Penalties scale with stake (percentages of the offender's at-risk stake at the time of offense):
| Offense | Penalty (% of stake) |
|---|---|
| Double signing (safety) | 100% + permanent ban |
| Equivocation (DAG fork at round) | 100% + permanent ban |
| Liveness < 90% per epoch | 1% per epoch |
| Liveness < 50% per epoch | 5% + jail (next epoch) |
| Liveness == 0% per epoch | 10% + forced unbonding |
| Invalid vertex production | 50% (with proof) |
| Decryption withholding | 2% per offense (jail at 3) |
| Sentry exposure violation | 1% (warning escalation) |
Of every slashed amount:
- 10% pays the evidence submitter (
FINDER_FEE_PERCENT). - 90% is burned.
This permissionless evidence-and-burn model means anyone who detects misbehavior is incentivized to submit it, and slashed PYDE is removed from circulation rather than redistributed (preventing perverse "slashing profit" incentives).
Indicative APY
APY = (annual_PYDE_rewards / staked_PYDE) × 100. Rewards distribute by
stake × uptime, so per-token yield is uniform across all validators —
only the absolute PYDE earned scales with stake. Committee participation
adds an activity-weighted bonus, but the base yield is the same.
At year 1, assume 5,000 active validators averaging 100K PYDE staked each (~500M total staked, modest middle ground while supply distributes), 128 selected to the active committee, modest fee volume, 60% of mint flowing to the reward pool:
Inflation share to reward pool (assume 60% of mint):
~30M PYDE / 500M total staked ≈ 6% APY on staked balance
Committee bonus (activity-weighted, 128 of 5000):
marginal additional ~0.5-1% APY during the ~3 hr epoch a validator
is on the committee (and 0 the rest of the time)
Average over a year: small uplift for active operators
Yields vary with how much total stake competes for the pool and where inflation sits on the taper:
| Year | Active validators | Avg stake | Total staked | Inflation | Indicative APY |
|---|---|---|---|---|---|
| 1 | ~1,000 | 100K | 100M | 5.0% | ~30% |
| 2 | ~5,000 | 100K | 500M | 3.0% | ~3.6% |
| 3 | ~10,000 | 100K | 1B (incl. inflation) | 2.0% | ~1.2% |
| 4+ | ~10,000 | 100K | 1B+ | 1.0% | ~0.6% |
Year 1 yields are high by design — bootstrap incentive while the validator set grows from genesis. As more validators come online, the per-token yield compresses naturally. The 1% terminal inflation rate plus the 20% fee-share keeps the steady-state validator economic viable without unbounded dilution.
The exact split between reward pool and treasury inside the inflation mint, and the trajectory of total validator count, are governance parameters; the numbers above are rough sketches, not commitments.
14.6 Vesting
Genesis allocations (team, ecosystem) are subject to on-chain vesting.
#![allow(unused)] fn main() { struct VestingSchedule { start_wave: u64, cliff_waves: u64, duration_waves: u64, total_amount: u128, } }
(crates/tx/src/vesting.rs:29-34, wire format 40 bytes:
start:8 || cliff:8 || duration:8 || total:16 LE)
Unlock curve
wave_id < start + cliff -> unlocked = 0
wave_id >= start + duration -> unlocked = total_amount
otherwise -> unlocked = total_amount * (wave_id - start) / duration
Cliff > duration safeguard
A genesis misconfiguration where cliff > duration would trap funds
forever (the cliff fires before the duration ends, then the duration
"ends" but the cliff still applies). The slice-5.1 audit fix prioritizes
end-of-vesting over cliff:
#![allow(unused)] fn main() { if wave_id >= start + duration { return total_amount; // FULL UNLOCK regardless of cliff } if wave_id < start + cliff { return 0; } // linear interpolation }
Plus genesis validation rejects schedules where cliff > duration.
Validation integration
Every transaction validation reads the sender's vesting schedule and
subtracts vesting.locked_at(current_wave_id) from the account's balance
before checking that the sender can pay gas_limit * base_fee + value. A
sender cannot transfer locked tokens — the protocol enforces it at
ingress.
14.7 Airdrop
Genesis ships an airdrop pool with claims gated by Merkle proof.
State
| Discriminator | Name | Holds |
|---|---|---|
0x18 | AIRDROP_ROOT | Merkle root of the airdrop list |
0x19 | AIRDROP_DEADLINE | Slot height after which sweep is allowed |
0x1A | AIRDROP_CLAIMED | Per-leaf-index claim flag |
0x1B | AIRDROP_EXPECTED_SUM | Genesis pool size (sanity check) |
The airdrop pool account lives at Poseidon2("pyde-airdrop-pool"). At
genesis, the pool is funded with AIRDROP_EXPECTED_SUM (sanity check
against drift between the off-chain Merkle builder and the genesis
balance).
Merkle tree format
Leaf: Poseidon2(0x00 || leaf_index_le8 || address || amount_le16)
Internal: poseidon2_pair(left, right)
Direction bit comes from the leaf_index (prevents sorted-pair attacks where
an attacker could swap left and right siblings to forge a proof).
Claim flow (tx type 7)
data = [leaf_index:8 LE][amount:16 LE][proof_len:1][sibling_0:32]...[sibling_N-1:32]
ClaimAirdrop handler:
1. Check current_wave_id <= AIRDROP_DEADLINE.
2. Check claim hasn't been redeemed (AIRDROP_CLAIMED bit unset).
3. Verify Merkle path against AIRDROP_ROOT.
4. Debit pool by amount; credit claimant.
5. Set the claim bit.
Gas: 30,000 base + 5,000 per Merkle level. Early gas guard rejects if
tx.gas_limit < required_gas before mutating any state — fixed in PR
#212 to prevent under-paid claims from drifting state. Max proof length is
255 levels.
Sweep flow (tx type 8)
After the deadline, anyone can call SweepAirdrop:
SweepAirdrop handler (any sender):
1. Check current_wave_id > AIRDROP_DEADLINE.
2. Move pool's residual balance to the treasury account.
Gas: 40,000 flat. The sweep is permissionless because the funds belong to the protocol — anyone can submit it once the window closes. The early-gas guard pattern applies here too.
14.8 Treasury
The treasury is a system account at Poseidon2("pyde-treasury"). It
accumulates value from three streams:
- Genesis allocation — direct allocation in the genesis config.
- Fee share — 10% of every transaction fee.
- Inflation share — a configurable share of per-block mint.
- Airdrop residual — whatever wasn't claimed by the deadline.
Treasury spending is always through the on-chain MultisigTx (tx
type 9). There is no other path that drains the treasury account
(enforced by the pipeline writeback-clobber protections — see §14.9).
MultisigTx payload
#![allow(unused)] fn main() { struct MultisigSpend { target: Address, value: u128, data_digest: [u8; 32], // hash(pip_file_contents) — audit trail to PIP } }
The data_digest field is the on-chain link to the off-chain PIP
(Pyde Improvement Proposal) document. Anyone auditing the chain can
recover the PIP from its hash, verify the signers approved that exact
spend, and trace the on-chain action back to a published proposal.
Multisig configuration
| Discriminator | Name | Holds |
|---|---|---|
0x1C | MULTISIG_SIGNERS | Length-prefixed array of FALCON pks |
0x1D | MULTISIG_THRESHOLD | Required signature count (u8) |
0x1E | MULTISIG_NONCE | Replay-protection counter |
Max signers: 16 (MAX_MULTISIG_SIGNERS). Each spend bumps
MULTISIG_NONCE so the same signed bytes cannot be replayed.
Wire format (MultisigPayload in crates/tx/src/multisig.rs):
[op_version: 1] [op_body: variable] [sig_count: 1]
[sig_entry_0] ... [sig_entry_N-1]
sig_entry = [signer_index: 1] [sig_len: 2 LE] [falcon_sig: sig_len]
op_version = 0x01 (MULTISIG_VERSION)
Gas: 50,000 base + 50,000 per signature.
Rotating the signer set
RotateMultisig (tx type 10):
#![allow(unused)] fn main() { struct MultisigRotate { new_signer_pks: Vec<Vec<u8>>, // each is a 897-byte FALCON pk new_threshold: u8, } }
Rotation requires the current signer set to authorize. Validation checks: at least one new signer, threshold ≤ new signer count.
Gas: 60,000 base + 50,000 per signature + 10,000 per new signer.
14.9 Emergency Pause
A multisig-authorized circuit breaker that halts all transactions except
EmergencyResume.
Pause (tx type 11)
#![allow(unused)] fn main() { struct EmergencyPausePayload { duration_waves: u64, sigs: Vec<SigEntry>, } }
duration_waves ∈ [1, MAX_PAUSE_DURATION_WAVES]where the cap is 6,500,000 slots (≈ 30 days). Reject zero or excessive durations.- Reject re-pause if the chain is already paused.
- Sets
EMERGENCY_PAUSE_END_WAVE(discriminator0x1F) =current_wave_id + duration_waves. - Bumps multisig nonce.
- Gas: 40,000 base + 50,000 per signature.
Resume (tx type 12)
#![allow(unused)] fn main() { struct EmergencyResumePayload { sigs: Vec<SigEntry>, } }
- Requires the chain to be currently paused.
- Zeros
EMERGENCY_PAUSE_END_WAVE. - Bumps multisig nonce.
- Gas: 40,000 base + 50,000 per signature.
Pause-gate semantics
is_paused(state, current_wave_id) returns true if
current_wave_id < EMERGENCY_PAUSE_END_WAVE. While paused, the pipeline
rejects every transaction type except EmergencyResume before
running validation or charging gas. This means a paused chain cannot be
spammed into draining gas budgets.
The pause auto-expires (current_wave_id >= end_wave) without an explicit
sweep — the gate just stops returning true. This means the worst case for
a runaway pause is the 30-day cap, never indefinite.
Use cases
- Critical bug discovered after audit but before fix is deployed.
- Active exploit being mitigated; pause halts state mutation until a fix ships.
- Coordinated upgrade window (rare; voluntary upgrades are the normal path — see Chapter 18).
The signer set should be picked specifically for crisis response (likely core developers + security team multisig), not the same set that signs treasury spends. This is a configuration decision, not a protocol constraint.
14.10 Writeback Clobber Protection
A subtle pipeline interaction: every transaction's post-execution stage
unconditionally writes the sender's and recipient's account state back to
the JMT. If a MultisigTx handler credits a target that collides with
either tx.from or tx.to, the writeback would overwrite the credit.
The fix:
MultisigTxrejects ifspend.target == tx.from(submitter).MultisigTxrejects iftx.to != Address::ZERO(must not collide with a regular tx target).
Same defenses are applied to RotateMultisig to prevent any signer
collision from clobbering the signer-set update.
14.11 Active-Stake Divisor and Unified Parsing
The pool-share calculation divides by ACTIVE_STAKE_WEIGHTED_TOTAL
(discriminator 0x15) — the sum of stake × uptime_share across every
validator currently in Active status. This diverges from
VALIDATOR_COUNT (the total registered count) once validators exit or
are slashed, and from a flat-per-validator divisor once validators
differ in stake or uptime (the common case across the two staking tiers).
Without this divisor, exited validators would dilute the pool share — even though they're not contributing security. Adjusted on:
StakeWithdraw(validator transitions toUnbonding; their stake weight is removed from the total)Slashof anActivevalidator (stake weight decreases, or removed entirely on jail/exit)- Each block where a validator's
uptime_sharechanges (lazy, indexed by the same accumulator pattern asREWARDS_PER_STAKE_UNIT)
ValidatorEntry parsing is unified through ValidatorEntry::decode() —
the same parser is used by every consensus and tx-handler call site.
Length: 4 + 897 (FALCON pk) + 16 (stake u128) + 1 (status) + 16
(last_claimed_at u128) = 934 bytes.
(This unification fixed a genesis bug where an earlier per-call-site
parser returned None on every genesis validator — surfaced and fixed in
multi-node test #228.)
14.12 Long-Run Equilibrium
The model targets:
| Phase | Net change |
|---|---|
| Year 1–2 | Net mint > burn → modest inflation |
| Year 3–5 | Burn ≈ mint → near-zero net change |
| Year 6+ (terminal) | Burn > mint → mild deflation |
The 1% terminal inflation rate × GENESIS_SUPPLY is around 10M PYDE per
year. Even modest sustained throughput (a few thousand TPS at typical
fee levels) burns more than that. Net deflation is the long-run
expected state.
Summary
| Property | Value |
|---|---|
| Native token | PYDE |
| Decimals | 9 (1 PYDE = 10^9 quanta) |
| Genesis supply | 1,000,000,000 PYDE |
| Supply cap | None (decreasing inflation, fee burn) |
| Inflation schedule | 5% → 3% → 2% → 1% (terminal) |
| Commits per year | ~63,113,904 (2/sec median) |
| Fee distribution | 70% burn / 20% reward pool / 10% treasury |
| Validator stake (min) | 10,000 PYDE (single tier, uniform-random committee selection) |
| Operator-identity cap | 3 validators per operator |
| Unbonding period | 30 days (must exceed 21-day safety evidence freshness) |
| Slashing finder fee | 10% of slashed amount |
| Vesting | On-chain, balance-locked at validation |
| Airdrop | Merkle-proof claim, Sweep after deadline |
| Treasury spend | MultisigTx (type 9) + PIP data_digest audit trail |
| Multisig signers | Up to 16; threshold rotatable via RotateMultisig |
| Multisig threshold (governance) | 7-of-12 typical (set at launch) |
| Emergency pause | EmergencyPause (type 11), max 30 days |
The next chapter covers governance — how PIPs (Pyde Improvement Proposals)
become on-chain MultisigTx actions, and what scope governance has versus
what's hard-coded.
Chapter 15: Governance
Pyde's governance is deliberately minimal at the protocol level. There is no two-chamber voting machine, no plutocratic stake-weighted ballot, no on-chain referendum logic. The model is off-chain Pyde Improvement Proposals (PIPs) + an on-chain treasury multisig, with everything else either hard-coded or operationally driven.
This chapter describes the actual governance design: how proposals form, how rough consensus is reached, what authority the on-chain multisig has, and what falls outside governance entirely.
15.1 Why "Off-Chain PIPs + On-Chain Multisig"
A small number of governance models are well-explored in production blockchains, each with distinct failure modes:
| Model | Common failure mode |
|---|---|
| Stake-weighted token voting | Plutocracy (whales decide, low turnout, capture) |
| Liquid democracy (delegation) | Concentrated delegates, unstable delegation |
| Two-chamber (validators + holders) | Procedural deadlock, complex thresholds |
| Off-chain BIP-style + voluntary upgrade | Real but slow |
| Council multisig | Centralized; depends on signer integrity |
Pyde's choice is closer to the Bitcoin BIP / Ethereum EIP model than to Cosmos-style on-chain governance:
- Proposals are documents, not on-chain ballots. They live in a public
pipsrepo (zarah-s/pips), open to any author, indexed and discussed in the open. - Adoption is via voluntary validator upgrade. When validators running a new client version reach a sufficient share of the active committee, the new behavior takes effect. Validators that don't upgrade continue running the old rules and either follow along (no consensus change) or fork off (consensus-breaking change).
- The on-chain treasury multisig executes spends linked to PIPs. The
MultisigTxpayload carriesdata_digest = hash(pip_file_contents), so every treasury action is on-chain-linked to a published PIP.
The model's core property: no party can drain the treasury, halt the chain, or change the rules without a coordinated, public, auditable process. Drainage requires multisig signers; chain halt requires the emergency multisig; rule changes require validators choosing to run the new code.
15.2 The PIP Process
PIPs are governed by PIP-0001, the founding document that ratifies the PIP system itself. The process at a high level:
PIP lifecycle:
1. Draft Author writes a markdown document (problem, design,
rationale, security considerations).
Creates a PR against zarah-s/pips.
2. Discussion Open discussion on the PR, in forums, etc.
Author iterates.
3. Review PIP receives review from core devs, validators, the
security team. Concerns are addressed in discussion.
4. Acceptance PIP is merged into the pips repo with a final number.
An acceptance signal does not change protocol behavior
by itself — it is documentation of rough consensus.
5. Implementation The PIP is implemented in a code change (PR against
the relevant Pyde repo). The PIP # is referenced.
6. Deployment The new node version ships. Validators choose to run
the new version. Once a sufficient validator share
upgrades, the change takes effect on-chain.
There is no on-chain "yes/no" vote on the PIP itself. The closest thing to a vote is validators choosing to run the new code — a softer but genuine signal.
What gets a PIP
| Change type | PIP needed? |
|---|---|
| Consensus rule change (block format, finality) | Yes |
| Gas cost changes | Yes |
| Fee distribution changes (e.g., 70/20/10 split) | Yes |
| Cryptographic primitive change | Yes |
| New transaction type | Yes |
| New WASM host function | Yes |
| Treasury spend (any size) | Yes (data_digest carries hash) |
| Bootstrap node list update | No (config-driven) |
| Bug-fix release (no protocol change) | No (changelog) |
| Doc updates | No |
What a PIP looks like
A PIP includes (at minimum):
- Problem statement — what is being addressed and why.
- Specification — the exact design / wire format / behavior change.
- Rationale — why this design over alternatives.
- Security considerations — what could go wrong.
- Backwards compatibility — does this require a coordinated upgrade?
- Reference implementation link — code PR(s) that implement it.
PIP-0001 specifies the template in detail.
15.3 Voluntary Validator Upgrade
How a consensus rule change actually takes effect:
1. PIP is accepted; reference implementation merged into Pyde repo.
2. A new node release is cut, including the new behavior.
3. Validators choose whether to upgrade to the new release.
4. If enough validators upgrade simultaneously, the new behavior takes
effect at the activation block (specified in the PIP).
5. Validators on the old release either continue producing the old rules
(forking off if the change is incompatible) or stay in sync (if the
change is opt-in or backward-compatible).
The key word is voluntary. There is no on-chain mechanism to force a validator to upgrade. Validators that reject a change keep running the old rules; if they constitute >1/3 of the active committee, the new behavior cannot reach finality and the change is effectively rejected by the network.
This means the upgrade decision is itself a kind of vote — not measured by token weight, but by validator participation. A controversial change that fails to attract supermajority validator participation simply doesn't land, regardless of how many off-chain signers nominally approved.
Activation parameters
Most consensus changes ship with an activation height — a specific
wave_id at which old nodes will produce waves the new nodes reject
(or vice versa). Validators run the upgrade window with both code paths
available, switching to the new path at the activation wave.
Backward-compatible changes (e.g., new opcodes that no existing contract uses) can ship without coordinated activation — they take effect when an upgraded validator processes the wave, are simply not used by old contracts, and become standard once enough nodes have upgraded.
15.4 The On-Chain Treasury Multisig
The one piece of "governance" that lives on-chain at mainnet is the treasury multisig. This is the mechanism by which approved PIPs that require funding turn into actual PYDE movement.
Configuration
State (recap from Chapter 14):
| Discriminator | Name | Holds |
|---|---|---|
0x1C | MULTISIG_SIGNERS | Length-prefixed array of FALCON pks |
0x1D | MULTISIG_THRESHOLD | Required signature count |
0x1E | MULTISIG_NONCE | Replay protection counter |
Maximum signers: 16. The threshold is t-of-n — requires t valid FALCON
signatures from distinct signers in MULTISIG_SIGNERS.
Suggested initial configuration (set at mainnet genesis): 12 signers, threshold 7, drawn from the Foundation board, core dev leads, validator operator representatives, and independent ecosystem representatives. The emergency-halt multisig is separate (typically a tighter 5-of-7 of core devs + security team for fast crisis response). The exact composition is a launch decision and will be ratified by PIP-0001 + a follow-up PIP.
Spend transaction (MultisigTx = type 9)
#![allow(unused)] fn main() { struct MultisigSpend { target: Address, // recipient value: u128, // PYDE quanta to send data_digest: [u8; 32], // hash(pip_file_contents) } }
The data_digest is the audit trail. Anyone reading the chain sees a
treasury spend (target, value, data_digest); anyone who has the PIP can
hash it and confirm the spend matches. If the digest does not match a
published PIP, that's a public, on-chain anomaly.
Validation enforces:
value > 0target != Address::ZEROtarget != treasury_address(cannot spend to self)target != tx.from(writeback-clobber protection)tx.to == Address::ZEROMULTISIG_NONCEmatches the signed payload (replay protection)- Number of valid signatures from
MULTISIG_SIGNERS≥MULTISIG_THRESHOLD - Each signer index referenced exactly once (no duplicates)
Gas: 50,000 base + 50,000 per signature.
Rotation (RotateMultisig = type 10)
#![allow(unused)] fn main() { struct MultisigRotate { new_signer_pks: Vec<Vec<u8>>, // each is 897-byte FALCON pk new_threshold: u8, } }
The current signer set authorizes the rotation. Validation requires:
new_threshold >= 1new_threshold <= new_signer_pks.len()new_signer_pks.len() <= MAX_MULTISIG_SIGNERS(16)- Same writeback-clobber defenses as
MultisigTx
Gas: 60,000 base + 50,000 per signature + 10,000 per new signer.
Why this isn't "centralized governance"
Critics of multisig-based governance often raise the centralization concern: "a few signers can do anything." The mitigating factors:
- Bounded scope. The multisig can spend the treasury and rotate itself. It cannot change the inflation schedule, the consensus rules, the gas distribution, or any other protocol parameter — those are hard-coded in the validator binary.
- Public, on-chain audit trail. Every spend has a
data_digestlinkable to a PIP. Off-chain spending the treasury is not possible. - Validator override. If the multisig were captured and started spending against published PIPs, validators could refuse to include the spend transactions (or hard-fork them out). Validators retain veto power even over the multisig.
- Rotatable. The signer set can be replaced, also via PIP + multisig action.
A captured multisig is a problem, but a bounded one — it cannot rewrite consensus or change supply.
15.5 Emergency Governance
Pyde has a separate EmergencyPause / EmergencyResume mechanism (also
multisig-authorized) for crisis response. Covered in Chapter 14 §14.9; the
governance-relevant points:
- The emergency multisig signer set is separate from the treasury multisig (the same configuration mechanism, different state slot in a proper deployment).
- Pausing requires the emergency signers; resuming requires the same.
- Pause is auto-expiring at
MAX_PAUSE_DURATION_WAVES(~30 days). A paused chain cannot stay paused indefinitely without a fresh authorization.
The recommended emergency signer set: core developers + security team, with a much lower threshold than the treasury multisig (so a quick response is possible during a live exploit). The exact configuration is a mainnet-launch decision.
15.6 What Is NOT Governable
Hard-coded protocol constants that cannot be changed by any on-chain action — only by a PIP + new validator binary release + voluntary validator upgrade:
| Constant | Where |
|---|---|
| DAG round period (~150 ms) | crates/consensus/src/round.rs |
| Commit cadence (~500 ms median) | crates/consensus/src/wave.rs |
| Committee size (128) | crates/consensus/src/committee.rs |
| Quorum / threshold (85) | crates/consensus/src/quorum.rs |
| Equivocation threshold (44) | crates/consensus/src/quorum.rs |
| Validator min stake (10,000 PYDE) | crates/tx/src/pipeline.rs (will move to shared crate post-consensus-rebuild) |
| Operator-identity cap (3 / operator) | crates/tx/src/pipeline.rs |
| Unbonding period (30 days) | crates/consensus/src/validator.rs |
| Inflation schedule | crates/tx/src/fee.rs |
| Fee split (70/20/10) | crates/tx/src/execution.rs |
| Gas target / ceiling | crates/tx/src/fee.rs |
MAX_TX_SIZE (128 KB) | crates/tx/src/validation.rs |
MAX_CALLDATA (64 KB) | crates/tx/src/validation.rs |
MAX_BATCH_SIZE (4 MB) | crates/mempool/src/batch.rs |
| Cryptographic primitives | pyde-crypto polyrepo (FALCON, Kyber, Blake3, Poseidon2) |
| WASM host function ABI | crates/wasm-exec/src/host_fns.rs + Host Function ABI spec doc |
Changing any of these requires a code release. Validators choose whether to run it.
15.7 What Falls Through the Gaps
Some operational concerns sit outside both the PIP process and the multisig:
| Concern | Handled by |
|---|---|
| Bootstrap node list | Config — operators ship their own |
| Block explorer | Foundation operates a public one |
| RPC endpoints | Multiple operators run them |
| Indexing / data products | Ecosystem builds them |
| Wallet integrations | Ecosystem partnerships |
| Marketing / branding | Foundation |
| Conference sponsorships | Treasury via PIP-driven multisig |
| Bug bounty payments | Treasury via PIP-driven multisig |
These are not "governance" in any rigorous sense. They are operational choices that the Foundation, validators, and ecosystem participants make independently.
15.8 Comparison with Other Networks
| Property | Pyde | Ethereum | Cosmos / Tendermint | Polkadot |
|---|---|---|---|---|
| Protocol-rule change | PIP + voluntary upgrade | EIP + voluntary upgrade | On-chain governance vote | Council + referenda |
| Treasury spend | On-chain multisig + PIP | Foundation grants | On-chain governance | On-chain treasury / Council |
| Emergency halt | Multisig pause | None at protocol layer | None at protocol layer | Sudo (pre-removal) |
| Token voting | None | None at protocol layer | Stake-weighted | Stake-weighted |
| Validator-only signal | Voluntary upgrade | Voluntary upgrade | On-chain | Council inclusion |
| Off-chain coordination doc | PIP | EIP | Forum + on-chain proposal | OpenGov / Forum |
| Constitutional parameters | All of them, hard-coded | Hard-coded | Some on-chain | Some on-chain |
The Pyde model is closest to Ethereum's: heavy reliance on off-chain proposals and voluntary validator upgrades, with a small on-chain mechanism (in our case, the treasury multisig) for the parts that genuinely need on-chain authorization.
15.9 Why No Stake-Weighted Voting?
Stake-weighted voting is the most common form of on-chain governance, and the design Pyde explicitly rejected. Three reasons:
- Plutocracy. A stake-weighted vote concentrates power in whoever holds the most tokens. PYDE distribution at any point in time is a snapshot — there's no reason to think it tracks anything beyond who bought early.
- Low turnout. Most token holders don't vote. The few who do gain outsized influence.
- Vote-buying. Active markets exist for vote delegation in stake-weighted systems. Treasury-spend votes can be auctioned off.
The PIP-and-voluntary-upgrade model removes the "vote weight" question entirely. There is no quantum of governance influence that can be purchased. There is only:
- Anyone can write a PIP.
- Validators can choose to run the resulting code (or not).
- Multisig signers can authorize PIP-linked treasury spends (or not).
Each piece is a clear, narrow authority. None of them aggregate into "control of the protocol."
15.10 Future Direction
Possible post-mainnet additions to governance, none on the critical path:
- Validator signal mechanism. A way for validators to publicly signal support or opposition for a PIP before activation, increasing process transparency. Pure off-chain or a thin on-chain log.
- Quadratic / conviction voting for treasury allocation. A sub-process for ecosystem grant allocation that gives some weighted input to ecosystem participants without becoming token-weighted control.
- Optional on-chain PIP registry. A storage-discriminator (
PIP_REGISTRY?) that mirrors the off-chain PIP repo so on-chain readers can resolve adata_digestwithout needing the off-chain repo.
None of these change the fundamental shape: the multisig is bounded, the PIP process is open, and validators decide what code they run.
Summary
| Component | Status at mainnet |
|---|---|
| PIP process | Off-chain, in zarah-s/pips |
| PIP authority | Documents intent; not protocol law |
| Validator upgrade | Voluntary; per-release |
| Treasury multisig | On-chain, MultisigTx (type 9) |
| Multisig rotation | On-chain, RotateMultisig (type 10) |
| Multisig signer cap | 16 |
MultisigTx PIP linkage | data_digest = hash(pip_file) on-chain |
| Emergency pause | On-chain, EmergencyPause (type 11) |
| Pause max window | ~30 days (auto-expiring) |
| On-chain stake-weighted voting | None |
| Hard-coded protocol constants | All of them — change via code release |
The next chapter covers security — the threat model, slashing detail, and the weak-subjectivity defenses that protect against long-range attacks.
Chapter 11: Account Model
The account model is the data structure at the center of every blockchain. It decides how users are identified, how balances are tracked, how authorization happens, and how concurrent transactions interact.
Pyde's account model is built on three ideas:
- Post-quantum from genesis. Addresses are derived from FALCON-512 public keys. There is no ECDSA legacy to migrate away from.
- Nonce window, not sequential. Each account gets a 16-slot nonce bitmap window — multiple in-flight txs without head-of-line blocking.
- Native account abstraction. Multisig, batch transactions, and paymaster sponsorship are protocol features, not application-layer add-ons.
This chapter covers the account record, address derivation, nonce mechanics, multisig configuration, batch transactions, and the transaction wire format.
11.1 Account Structure
Every account in crates/account/src/types.rs:
#![allow(unused)] fn main() { struct Account { address: Address, // 32 bytes (Poseidon2 hash of FALCON pk) nonce: u64, // 8 B -- low end of the 16-slot window balance: u128, // 16 B -- spendable balance, in quanta code_hash: H256, // 32 B -- 0x00..00 for EOAs storage_root: H256, // 32 B -- 0x00..00 for empty contracts account_type: AccountType,// 1 B -- EOA=0, Contract=1, System=2 auth_keys: AuthKeys, // variable -- see §11.7 gas_tank: u128, // 16 B -- sponsored-tx pool key_nonce: u32, // 4 B -- key-rotation counter } }
Fixed-portion size: 141 bytes plus the variable auth_keys field. The
encoding is little-endian, dense; the JMT stores the serialized blob as
the leaf value.
| Field | Mutability |
|---|---|
address | immutable after account creation |
nonce | per-tx (window slides forward) |
balance | per-tx |
code_hash | set once at deploy; never changes |
storage_root | every block that mutates the contract |
account_type | immutable |
auth_keys | rotatable (increments key_nonce) |
gas_tank | deposit by anyone; withdraw by owner |
key_nonce | increments on key rotation |
The "spendable" balance is what's available after deducting any vesting
locks (Chapter 14). The vesting subsystem reads the on-chain
VestingSchedule for the account and subtracts the locked portion before
checking balance during validation.
11.2 Address Derivation
All Pyde addresses are 32-byte Poseidon2 hashes. The derivation depends on how the account is created.
EOA
EOA address = Poseidon2(falcon_public_key_bytes)
The input is the raw 897-byte FALCON-512 public key. The output is 32 bytes of Poseidon2 over the Goldilocks field — the natural output size, no truncation.
CREATE (deploy from a deployer's nonce)
CREATE address = Poseidon2(deployer_address || nonce_bytes)
The deployer's address and the deployer's current nonce — the same scheme as Ethereum, but Poseidon2 instead of Keccak.
CREATE2 (deterministic deploy with a salt)
CREATE2 address = Poseidon2(0xFF || deployer_address || salt || code_hash)
The leading 0xFF is a domain separator that distinguishes CREATE2 outputs
from CREATE outputs (so two different derivation inputs can never collide).
Why 32 bytes (not 20)
A 20-byte address provides 80-bit collision resistance, which is marginal at chain scale. Pyde uses the full 32 bytes — 128-bit collision resistance — which matches the natural Poseidon2 output. There is no storage cost worth saving by truncating.
11.3 Account Types
#![allow(unused)] fn main() { enum AccountType { EOA = 0x00, Contract = 0x01, System = 0x02, } }
EOA
The standard user account. Has a single FALCON pubkey (or a multisig set)
in auth_keys. No code, no storage. Balance and nonce live directly in the
account record.
Contract
Has deployed WASM bytecode (code_hash != 0) and optionally a storage trie
(storage_root != 0). Cannot directly initiate transactions — only
respond to calls. May have a non-empty gas_tank to sponsor user calls
into it.
System
Pre-existing accounts at deterministic addresses for protocol-level
operations (treasury, airdrop pool, validator entries). Their addresses are
typically Poseidon2("pyde-treasury") or similar — not derived from any
public key. They are seeded at genesis and only mutated by specific
transaction handlers (e.g. the treasury balance moves only via MultisigTx
spend or fee-split crediting).
11.4 Nonce Bitmap Window
Sequential nonces (Ethereum's model) cause head-of-line blocking: if a tx at nonce 5 is stuck (e.g., dependent on a state change that hasn't happened), all higher nonces from the same sender are blocked behind it.
Pyde uses a 16-slot bitmap window:
#![allow(unused)] fn main() { pub const WINDOW_SIZE: u64 = 16; struct NonceState { base: u64, // lowest unused nonce used: u16, // bitmap: bit i = nonce (base + i) used } }
A transaction can use any nonce in [base, base + 15]. The bitmap tracks
which slots are filled. When the lowest bit becomes set, the window slides
forward past every consecutive used slot.
#![allow(unused)] fn main() { fn use_nonce(state: &mut NonceState, n: u64) -> Result<(), Error> { if n < state.base || n >= state.base + 16 { return Err(NonceOutOfWindow); } let offset = (n - state.base) as u16; let bit = 1 << offset; if state.used & bit != 0 { return Err(NonceAlreadyUsed); } state.used |= bit; while state.used & 1 == 1 { // slide window past contiguous used state.base += 1; state.used >>= 1; } Ok(()) } }
Worked example
Initial: base=100, used=0b0000000000000000 window [100..115]
Submit tx with nonce=103:
base=100, used=0b0000000000001000 100,101,102 still available
Submit tx with nonce=100:
(slide) base=101, used=0b0000000000000100 window [101..116]
Submit tx with nonce=101:
(slide past 101 and 102 -- 103 is set)
base=102, used=0b0000000000000010 window [102..117]
Submit tx with nonce=102:
base=104, used=0b0000000000000000 window [104..119]
Properties
| Property | Outcome |
|---|---|
| Concurrent submissions | Up to 16 in-flight from one sender |
| Stuck-tx tolerance | A stuck nonce N doesn't block N+1, N+2, ... |
| Replay protection | Each (account, nonce) usable exactly once |
| Cancellation | Submit a different tx with the same nonce |
| Compact state | 10 bytes of nonce state per account |
Limit
If a power user genuinely needs more than 16 in-flight, they use multiple accounts. In practice, even high-frequency market makers rarely exceed 16 pending — at ~500ms median commit and v1 target TPS, the queue drains in a handful of waves.
11.5 Authorization: AuthKeys
Each account stores a auth_keys field that determines who is allowed to
sign for it:
#![allow(unused)] fn main() { enum AuthKeys { None, // tag 0x00 Single(Vec<u8>), // tag 0x01 — FALCON pk MultiSig { keys: Vec<Vec<u8>>, threshold: u32 }, // tag 0x02 — max 16 signers Programmable, // tag 0x03 — RESERVED v2 } }
| Variant | Status | Used for |
|---|---|---|
None | v1 | System accounts, contracts that have no admin |
Single | v1 | Standard EOA — one FALCON-512 public key (~897 bytes) |
MultiSig | v1 | Native multi-signature — set of keys + threshold (max 16) |
Programmable | v2 reserved | Contract-defined auth logic (session keys, social recovery, biometric, etc.) — discriminant is reserved at v1 so contracts written today survive the v2 upgrade without rewriting |
Why native multisig at v1. Gnosis Safe's contract-based multisig on
Ethereum has been re-implemented dozens of times across projects with
subtle bugs in each. Pyde standardizes the simple t-of-n case as a
protocol primitive, so wallets and contracts can rely on a single audited
implementation. Weighted multisig and exotic schemes still live at the
contract layer.
The Programmable reservation. Reserving 0x03 at v1 means contracts
that today reference AuthKeys::Programmable (as a future-proofing hint)
won't break at the v2 upgrade — the discriminant is allocated. Session
keys, social recovery, and biometric auth are post-mainnet features. See
Session keys (v2) below for the design and what v1 reserves to make
that work.
A Single EOA uses one FALCON pubkey for all transactions. A MultiSig
account requires threshold-of-N signatures to authorize.
Key rotation
Both Single and MultiSig are mutable. A key rotation transaction signed
by the current auth_keys updates the field and increments
key_nonce by 1. The increment invalidates any in-flight transaction
signed under the old key — they will fail signature verification on
inclusion.
The address itself never changes. Storing addresses in contracts (for balances, allowances, ACLs) remains valid across any number of key rotations.
Why no native key-recovery
Pyde does not ship a built-in social-recovery scheme. The intended pattern
for high-value accounts is MultiSig with guardian keys:
keys: [
Owner (weight 3 if you implement weighted multisig in a contract),
Guardian_1 (weight 1),
Guardian_2 (weight 1),
Guardian_3 (weight 1),
]
threshold: 3
Normal: Owner signs alone (weight 3 == threshold).
Recovery: Three guardians together (1+1+1 = 3) authorize a key rotation.
The base MultiSig variant in AuthKeys provides equal-weight t-of-n.
Weighted variants live at the contract layer (a deployed multisig contract
that owns the EOA via key rotation).
Session keys (v2)
A session key is a temporary, scope-limited key the user authorizes a dApp (or an agent) to act with on their behalf — for a bounded time, against a bounded set of contracts, with a bounded spend cap. The user signs once. The dApp signs many times, within the declared scope, without ever holding the user's main key.
This is the UX layer most consumer crypto applications have been missing. Pyde ships native session-key support at v2 (paired with programmable accounts). Ethereum is retrofitting the same idea via ERC-4337; Pyde gets it at the protocol layer.
Use cases:
- Gaming. Sign once at session start; play 200 in-game actions without per-action wallet popups.
- AI agents. Delegate "trade at most 100 PYDE/day on this DEX until next Friday" without handing over the master key.
- Consumer apps. Recurring subscriptions, micro-transactions, real-time DeFi positions.
- Embedded wallets. Passkey-style flows where the user's main key never leaves a secure enclave.
How it works (v2):
#![allow(unused)] fn main() { struct SessionKey { pubkey: FalconPubkey, // the delegated key scope: SessionScope, // what it can do expires_at: WaveId, // when it stops working revoked: bool, // owner-flippable kill switch } struct SessionScope { contracts: Vec<Address>, // allow-list of callable contracts methods: Vec<Selector>, // optional method allow-list (empty = all) max_spend: u128, // hard cap on cumulative PYDE outflow spent_so_far: u128, // running counter, updated at commit } }
At authorization time, for any tx submitted under a session key, the protocol checks:
- Signature. FALCON-verify against
SessionKey.pubkey. - Liveness.
expires_at > current_waveandrevoked == false. - Scope. Target contract is in
scope.contracts; ifscope.methodsis non-empty, the called selector is in it. - Spend cap.
spent_so_far + tx.value ≤ max_spend.
All four must pass. On commit, spent_so_far is incremented atomically.
The account's main auth_keys is untouched — session keys are an
additional authorization path, not a replacement.
Revocation. A RevokeSessionKey tx signed by the account's main
auth_keys flips revoked = true. The session is invalid from the next
wave onward.
Why v2, not v1. Session keys are a specific policy expressed in the
AuthKeys::Programmable variant. They need the policy engine that
programmable accounts ship with. Both move together at v2.
What v1 reserves to make this work:
| v1 surface | Why it matters for v2 session keys |
|---|---|
AuthKeys::Programmable enum variant (tag 0x03) | The authorization model session keys plug into |
Account code_hash + storage_root fields | Programmable accounts use the same shape as contracts |
| WASM "policy mode" execution flag (reserved) | Session-key checks run in a restricted-state-access mode |
| Multisig signature pipeline | Same verification path serves session-key + multisig flows |
These reservations cost nothing at v1 (the enum variant is unused, the policy-mode flag is reserved-but-not-implemented). v2 ships session keys without breaking any account-touching contract written for v1.
11.6 Transaction Wire Format
A transaction in crates/tx/src/types.rs:
#![allow(unused)] fn main() { struct Transaction { from: Address, // 32 B to: Address, // 32 B (Address::ZERO for deploy) value: u128, // 16 B (in quanta) data: Vec<u8>, // calldata or initcode gas_limit: u64, // 8 B nonce: u64, // 8 B (in [base, base+15]) signature: FalconSig, // ~666 B fee_payer: FeePayer, // tag + optional address (1-33 B) access_list: Vec<AccessEntry>, deadline: Option<u64>, // 0 or 8 B chain_id: u64, // 8 B tx_type: TransactionType,// 1 B (see §11.8) } }
fee_payer
#![allow(unused)] fn main() { enum FeePayer { Sender, // pays from their own balance (default) GasTank, // gas paid from the target contract's gas_tank Paymaster(Address), // gas paid by named paymaster (calls validator) } }
See Chapter 10 for sponsorship semantics.
access_list
#![allow(unused)] fn main() { struct AccessEntry { address: Address, storage_keys: Vec<U256>, access_type: AccessType, // Read | ReadWrite } }
The access list is a prefetch hint that optimizes cache warm-up (PIP-3
multiget); v1 execution is uniform Block-STM and runs every tx in parallel
regardless of whether a list is supplied. Wallets generate it automatically
by simulating the transaction (pyde_createAccessList) and attach it to
the signed transaction. (Runtime enforcement of declared access_list scope
is a v2 hardening; v1 treats the list as a prefetch hint without runtime
validation.)
deadline
A wave_id after which the tx becomes invalid. If included before
deadline it executes normally; if not, it is dropped from mempools and
the nonce slot frees up. Recommended values:
| Use case | Deadline (waves after submission) | Wall time |
|---|---|---|
| DEX swap | +20 | ~10 sec |
| Token transfer | +120 | ~60 sec |
| Mint | +600 | ~5 min |
| Governance vote | +28,800 | ~4 hr |
| No urgency | None | indefinite |
Transaction hash
Computed via Poseidon2 over the canonical encoding of all fields. The signature is over this hash:
tx_hash = Poseidon2(
chain_id || from || to || value || Poseidon2(data) || gas_limit || nonce ||
fee_payer_tag || Poseidon2(access_list) || deadline || tx_type
)
data and access_list are pre-hashed to keep the outer Poseidon2 input
size bounded.
Typical sizes
A simple transfer (no calldata, no access list, no deadline) is roughly
780 bytes — dominated by the FALCON-512 signature. A complex tx with a
populated access list and several KB of calldata can reach the 128 KB
MAX_TX_SIZE.
11.7 Multisig Treasury Spend
Beyond per-account multisig (where auth_keys = MultiSig{...}), Pyde has a
treasury-level multisig for protocol-funded actions. This is what
moves PYDE out of the treasury account when a PIP is approved.
The mechanism uses two new transaction types:
| Type ID | Name | Purpose |
|---|---|---|
| 9 | MultisigTx | Treasury spend: debit treasury, credit target |
| 10 | RotateMultisig | Rotate the signer set + threshold |
The current signer set and threshold live in state under the discriminators
MULTISIG_SIGNERS / MULTISIG_THRESHOLD; replay is prevented by
MULTISIG_NONCE. See Chapter 15 for the governance flow that produces
these signatures.
The handler enforces:
value > 0target != Address::ZEROtarget != treasury_addresstarget != tx.from(prevents pipeline-writeback clobber)tx.to == Address::ZERO(must not collide with a regular tx target)
The signature count + threshold check happens against the on-chain signer
set. A successful spend bumps MULTISIG_NONCE so the same signed payload
cannot be replayed.
11.8 Transaction Types
The TransactionType enum (in crates/tx/src/types.rs) currently has 13
variants. Tag 2 is intentionally vacant — Batch was prototyped pre-mainnet but removed before launch (the dispatch arm was a 21k-gas no-op and never wired to real semantics; keeping the gap means a forged tx_type = 2 fails decode rather than silently aliasing to another type).
| ID | Name | What it does |
|---|---|---|
| 0 | Standard | Value transfer or contract call |
| 1 | Deploy | Contract deployment (to == Address::ZERO, data == initcode) |
| 3 | StakeDeposit | Lock ≥ MIN_VALIDATOR_STAKE (10,000 PYDE) and register as validator (data = FALCON pubkey 897 B). Single-tier — any validator meeting the floor is eligible for the per-epoch uniform-random committee selection (see Chapter 14 §14.5). |
| 4 | StakeWithdraw | Begin 30-day unbonding |
| 5 | Slash | Submit double-sign evidence (data = serialized evidence) |
| 6 | ClaimReward | Claim accrued staking yield from the pool |
| 7 | ClaimAirdrop | Claim genesis airdrop with Merkle proof |
| 8 | SweepAirdrop | Move unclaimed airdrop residue to treasury (post-deadline) |
| 9 | MultisigTx | Treasury spend with multisig signatures |
| 10 | RotateMultisig | Rotate multisig signer set + threshold |
| 11 | EmergencyPause | Halt block production (multisig-signed) |
| 12 | EmergencyResume | Resume normal processing (multisig-signed, clears pause) |
| 13 | RegisterPubkey | First-time pubkey registration for a funded-but-unregistered account. No signature, no gas, no value — proof of pubkey ownership is the address-derivation check (only the keypair holder can produce a pubkey that hashes to a given address). Allowed only when balance > 0 and auth_keys == AuthKeys::None. After execution, auth_keys = AuthKeys::Single(tx.data) and the account can sign normal txs. |
Each handler in crates/tx/src/pipeline.rs validates the type-specific
payload, applies the state effect, and runs through the same fee
distribution + post-execution writeback. Unknown discriminators are
rejected at validation.
11.9 Batch Transactions (removed pre-mainnet)
Multi-operation batch transactions were prototyped under tag 2 but
removed before launch. The dispatch arm was a 21k-gas no-op never wired
to real semantics, and ABI-level multi-call patterns (a contract that
takes a Vec<(Address, u128, bytes)> and dispatches internally) cover
the same use cases without protocol-level complexity. Tag 2 remains
reserved (decodes to None) so a forged transaction with tx_type = 2
fails decode rather than silently aliasing to another variant.
If multi-op atomicity becomes a documented need post-mainnet, a future PIP can re-introduce the variant at the next unused tag with a real implementation.
11.10 Contract Code and Storage
Deployment
#![allow(unused)] fn main() { Transaction { from: deployer, to: Address::ZERO, // signals deployment value: ..., data: init_bytecode, // executed once at deploy gas_limit: ..., nonce: ..., tx_type: TransactionType::Deploy, ... } }
wasmtime instantiates init_bytecode against a fresh context. The init code's
return value is stored as the contract's runtime bytecode. The deployed
contract address is Poseidon2(deployer || nonce) (see §11.2). The
code_hash is set to Poseidon2(runtime_bytecode).
After deployment, the code_hash is immutable. Upgradeability is
handled at the application layer with the proxy pattern:
+-----------+ DELEGATECALL +------------------+
| Proxy | ---------------------------> | Implementation |
| (fixed) | | (v1, v2, v3) |
| storage: | proxy uses its own storage | no storage of |
| current_ | but executes the impl's code | its own |
| impl | +------------------+
+-----------+
The proxy's address never changes; upgrading is a single state write to
current_impl in the proxy's storage.
Storage schema
The otigen toolchain's build-time storage layout (Chapter 5) and the JMT key derivation
(Chapter 4) together produce a fully typed storage model. There is no
"random raw 256-bit slot" — every storage access is keyed against the
contract address with a discriminator that came from a typed declaration.
11.11 Account State in the JMT
Accounts and their storage all live in the same JMT. A single Merkle path from the JMT root proves any claim about any account.
To prove "Alice's balance at wave W equals X":
- Show the JMT path from the wave-W state root (in the commit header) to Alice's account leaf.
- Decode the account record; read the
balancefield.
There is no separate "account trie" + "storage trie" indirection. One root, one path, one proof.
Light clients use this property to verify state without storing the full chain — they need block headers and on-demand JMT proofs from full nodes.
11.12 Worked Lifecycle: Sponsored Token Transfer
Step 1 — Wallet builds tx
from: 0xpyde1abc... (Alice)
to: 0xpyde1def... (DEX contract)
value: 0
data: swap(USDC, PYDE, 1000)
gas_limit: 150,000
nonce: 42 (within Alice's nonce window)
fee_payer: GasTank <- DEX contract pays
deadline: block 2,000,025 (10 sec from now)
chain_id: 1
tx_type: Standard
signature: FALCON-512(Alice's sk, hash of all fields)
Step 2 — RPC ingress
- chain_id matches
- FALCON sig verifies against Alice's auth_keys
- nonce 42 is in [40, 55]
- DEX.gas_tank >= 150_000 * base_fee
- deadline > current_wave_id
- access_list dedup OK
- tx size + calldata size within limits
-> ENQUEUE on gossip channel BEFORE returning Ok
Step 3 — Mempool propagation
Encrypted payload reaches every node's mempool via gossipsub.
Step 4 — DAG vertex production (round R)
Tx referenced by batch hash in worker batch; each committee member's
primary references the batch in its round-R vertex.
Step 5 — Commit (round R+3, ~500 ms after submission)
Deterministic anchor commits the subdag; canonical order emitted.
Step 6 — Threshold decryption (rounds R+4 to R+5)
85+ Kyber shares -> shared_secret -> AES decrypt payload.
Step 7 — Execution (Block-STM)
- Gas charged from DEX.gas_tank (FeePayer::GasTank); accounted via wasmtime fuel.
- Access list drives PIP-3 multiget prefetch into dashmap before workers start.
- Block-STM runs every tx in the wave optimistically in parallel; MVCC
catches conflicts at validation; losers re-execute until fixpoint.
- DEX swap logic runs: SLOAD reserves, SLOAD/SSTORE Alice's USDC,
transfer PYDE to Alice.
- Total gas used: 87,400.
Step 8 — Fee distribution
total_fee = 87,400 * base_fee
burn: 70%
reward pool: 20% (distributed at epoch end across stakers)
treasury: 10%
Debited from DEX.gas_tank.
Step 9 — State writeback
Alice's USDC balance updated, PYDE balance updated, DEX gas_tank
debited. Alice's nonce 42 marked used; window slides if 40, 41 also used.
Step 10 — Finality (state root attestation, ~500 ms median end-to-end)
85+ FALCON state-root sigs piggybacked on subsequent vertices.
Step 11 — Receipt
pyde_getTransactionReceipt returns success, gas_used, logs, fee_paid.
Summary
| Property | Value |
|---|---|
| Address size | 32 bytes (Poseidon2 hash, no truncation) |
| Address derivation | EOA from FALCON pk; CREATE / CREATE2 from deployer |
| Account types | EOA, Contract, System |
| Auth schemes | None, Single FALCON pk, MultiSig{keys, threshold} |
| Address mutability | Immutable across key rotations |
| Nonce window | 16 slots (bitmap), sliding base |
| Native account abstraction | Yes (fee_payer = GasTank / Paymaster(addr)) |
| Multisig per-account | Yes (via AuthKeys::MultiSig) |
| Multisig treasury | Yes (MultisigTx = type 9) |
| Batch transactions | Removed pre-mainnet (tag 2 reserved-as-vacant) |
| Transaction types | 13 active (Standard, Deploy, Stake*, Slash, Claim*, Sweep*, Multisig*, Emergency*, RegisterPubkey) |
| Validation gas cap | 100,000 for paymaster validation |
The next chapter covers the networking layer that ferries all these transactions between nodes — libp2p, QUIC, the four gossipsub channels, and the FALCON peer-attestation handshake.
Chapter 12: Networking
Pyde's P2P network sits on libp2p over QUIC, with purpose-specific gossipsub channels and an application-layer FALCON-512 handshake that binds each peer's libp2p PeerId to a post-quantum identity. Peer discovery uses a layered approach (no Kademlia DHT) — hardcoded seeds, then DNS, then the on-chain validator registry, then PEX. This was a deliberate post-pivot choice: a DHT for a 128-member committee is more attack surface than it is value.
Worker / Primary split (Narwhal pattern). Within each validator, transactions and consensus traffic are decoupled. Workers gossip transaction batches peer-to-peer (high-volume data dissemination); primaries gossip vertices (low-volume consensus structure). A vertex carries batch hashes by reference, never full payloads.
The encryption story is layered — libp2p's standard Ed25519/X25519 handles peer routing, FALCON does the heavy lifting at the application layer where quantum-safety matters. Committee defense uses the sentry node pattern (Cosmos-style): committee validators are reachable only through sentry proxies, never expose their committee identity to public peers.
12.1 Transport: libp2p over QUIC
Why libp2p
libp2p is the modular networking stack used by Ethereum 2.0, Filecoin, and Polkadot. It gives Pyde:
- Pluggable transport (Pyde uses QUIC).
- Multistream protocol negotiation per stream.
- Built-in Kademlia DHT and gossipsub implementations.
- Peer identity via PeerId.
Why QUIC
| Property | TCP + Yamux/mplex | QUIC |
|---|---|---|
| Connection setup | 1-3 RTT (TCP + TLS) | 0-1 RTT (integrated TLS) |
| Head-of-line blocking | yes (all streams share) | no (per-stream flow control) |
| Multiplexing | userspace (Yamux) | native (kernel-assisted) |
| Connection migration | not supported | supported (connection IDs) |
| Mandatory encryption | optional (TLS) | always (TLS 1.3 in handshake) |
Per-stream independence matters most when block propagation (large) and consensus votes (latency-critical) share the same QUIC connection. A single lost packet on the block stream does not stall the vote stream.
The libp2p config is set up in crates/net/src/node.rs via
SwarmBuilder::with_quic().
Identity at the libp2p layer
libp2p PeerIds in Pyde are derived from Ed25519 / X25519 keys — the libp2p default. The choice is intentional: libp2p's PeerId routing, Kademlia DHT lookups, and QUIC handshake all assume one of the supported key types. Replacing the libp2p layer's identity with FALCON would require a custom libp2p fork.
The post-quantum identity layer sits one step higher: every consensus and validator-channel message is signed with FALCON-512, and the application-level peer handshake (§12.4) binds the libp2p PeerId to a FALCON public key. Pyde's threat model treats the libp2p layer as fungible peer routing; the cryptographic claims that matter — vote authenticity, finality cert verification, evidence verification — all sit on FALCON.
12.2 The Four Channels
Different traffic has different latency and throughput profiles. Mixing them on one gossip topic forces the worst-case scheduling on every message type. Pyde splits traffic into four channels, each tuned for its workload.
+---------------------------------------------------------------+
| Pyde Node |
| |
| +-------------+ +-------------+ +-------------+ +----------+ |
| | Consensus | | Transactions| | Blocks | | Sync | |
| | gossip | | gossip | | gossip | | req/resp | |
| +------+------+ +------+------+ +------+------+ +----+-----+ |
| | | | | |
| +------+---------------+---------------+-------------+------+ |
| | libp2p / gossipsub | |
| +------+----------------------------------------------------+ |
| | |
| +------+----------------------------------------------------+ |
| | QUIC transport | |
| +-----------------------------------------------------------+ |
+---------------------------------------------------------------+
| Topic | Participants | Size limit | What it carries |
|---|---|---|---|
pyde/vertices/1 | Committee primaries | 256 KB | DAG vertices (batch refs + parent refs + state-root sigs + decryption shares + FALCON sig) |
pyde/transactions/1 | All nodes | 128 KB | User transactions (plaintext or encrypted) |
pyde/batches/1 | Workers + primaries | 4 MB | Worker batches (hard cap; preserves modest-hardware claim) |
pyde/sync/1 | All nodes (req/resp) | 16 MB | Snapshot chunks (4 MB typical), historical vertices |
pyde/evidence/1 | Validators | 64 KB | Slashing evidence (double-sign, equivocation, etc.) |
Validator-only vertex channel
Non-validator peers are dropped from the pyde/vertices/1 and
pyde/evidence/1 topics. The check
(ChannelAccess::validator_only() in crates/net/src/channels.rs) refuses
to forward messages from peers whose FALCON-attested pubkey is not in the
current committee set. A non-validator that subscribes to the topic gets
ValidationResult::Reject on every publish.
This matters: the vertex channel carries committee FALCON sigs, piggybacked decryption shares, and state-root attestations. A malicious non-validator that could flood the channel could DoS the commit pipeline. The validator-only filter prevents this by construction.
Per-channel size limits
The validator (crates/net/src/channels.rs) checks the message size
against the per-channel cap before forwarding. Oversized messages are
rejected and the originating peer takes a reputation penalty.
12.3 Gossipsub Configuration
crates/net/src/node.rs configures gossipsub:
| Parameter | Value | Why |
|---|---|---|
validation_mode | Permissive | Auto-forward; see throughput note |
heartbeat_interval | 150 ms | Matches DAG round cadence; amortizes mesh maintenance without blocking round progress |
mesh_n | 8 | Mesh size per node |
mesh_n_low | 4 | Trigger mesh expansion |
mesh_n_high | 12 | Trigger mesh pruning |
gossip_lazy | 8 | Number of IHAVE peers |
history_length | 6 | Recent message-id buffer (heartbeats) |
history_gossip | 3 | Size of the IHAVE batch |
duplicate_cache_time | 60 s | Dedup window — handles small-net jitter |
flood_publish | true | Initial publish reaches all mesh peers |
max_transmit_size | 1 MB | Per-message cap (channels override) |
The Permissive + flood_publish change
Strict gossipsub validation requires the application layer to call
report_message_validation_result for every message before it gets
forwarded. Earlier Pyde code didn't do this on every path — the result was
that, on a small (4-validator) testnet, transactions only reached the
direct peer of the submitting node. They never propagated through the
mesh.
The fix (commit 2018b17) was twofold:
- Switch to
ValidationMode::Permissive, which auto-forwards a message once the basic structural check passes. - Set
flood_publish = trueso the initial publish from a node reaches all of its mesh peers immediately, not just a random subset.
The combination raised sustained TPS from ~1K to ~4K on the same testnet
hardware. There is also a paired change in the wave executor that skips
redundant per-tx FALCON verification when the wave-level batched verify
already passed (block_sigs_pre_verified flag in WaveContext) —
roughly 70% reduction in wave-execution CPU.
12.4 FALCON P2P Handshake
After a libp2p connection is established, the two peers run a FALCON attestation exchange to bind the libp2p PeerId to a post-quantum identity.
#![allow(unused)] fn main() { // crates/net/src/auth.rs struct PydeAuthReq { nonce: [u8; 32] } struct PydeAuthResp { falcon_pubkey: Vec<u8>, // ~897 bytes signature: Vec<u8>, // FALCON over (nonce || responder_peer_id_bytes) } }
Flow
A (initiator) B (responder)
| |
| --- PydeAuthReq(nonce) ------->|
| |
| | sign msg = nonce || responder_peer_id_bytes
| | with B's FALCON sk
| |
| <-- PydeAuthResp(pk, sig) -----|
| |
| verify(pk, msg, sig) |
| record (peer_id -> pk) |
| |
verify_auth_resp(req, resp, peer_id) parses the pubkey, reconstructs the
attestation message, and runs falcon_verify. On success, the binding
(peer_id -> falcon_pubkey) is recorded in the local PeerManager.
Outcome
#![allow(unused)] fn main() { enum AuthOutcome { NoPendingNonce, // attempt to respond with no outstanding req VerifyFailed, // FALCON sig invalid RebindRejected, // peer tried to bind a different pubkey StoredAsValidator, // pubkey is in current committee_keys StoredAsNonValidator, // pubkey is not in committee } }
A RebindRejected is suspicious — once a PeerId is bound to a FALCON
pubkey, attempts to re-bind it are denied (a PeerId switching pubkeys mid
session is either a bug or an attack).
Validator-channel filtering uses this binding
Every gossipsub message on pyde/consensus/1 is checked against the
attested pubkey of the publishing peer. Non-validators (no committee
membership) get their messages dropped before any heavyweight verification
runs. This is the cheap front-line filter that keeps consensus traffic
clean.
12.5 Peer Discovery (Layered, No DHT)
Pyde does not use a Kademlia DHT. The pre-pivot design did, until we audited the security profile: a DHT for a 128-member committee gives an attacker a controllable lookup surface (Sybil flooding of routing tables, eclipse via DHT poisoning) without offering value the committee couldn't get from simpler mechanisms.
Discovery proceeds in five layers, each falling back to the next:
1. Hardcoded bootstrap seeds (chain spec ships ~10 well-known IPs)
2. DNS seed lookup (TXT records at seed.pyde.network)
3. On-chain validator registry (each validator's PeerId+addr on-chain)
4. Peer exchange (PEX) (peers gossip their connected-peer list)
5. Local cache (recently-seen-good peers persisted)
Bootstrap
The chain spec ships hardcoded bootstrap seeds + the DNS seed name. At startup the node dials seeds in parallel, performs FALCON handshakes, and queries each seed's connected-peer list (PEX) to expand the candidate set.
# in pyde.toml
[network]
bootstrap_seeds = [
"/dns4/seed1.pyde.network/udp/30303/quic-v1/p2p/12D3Koo...",
"/dns4/seed2.pyde.network/udp/30303/quic-v1/p2p/12D3Koo...",
]
dns_seed = "seed.pyde.network"
On-chain validator registry
Each committee validator's (falcon_pubkey, peer_id, multiaddr) is on
chain in the validator-registry account, updated when a validator joins
the committee. A new node fetching the genesis block (or any later state
snapshot) has the complete committee directory — no DHT lookup required.
Peer exchange (PEX)
Once connected, peers periodically gossip a short list of other peers
they're currently connected to. PEX uses a small dedicated request/response
protocol (/pyde/pex/1) — not the gossipsub channels — to avoid mixing
discovery traffic with consensus.
Why this is enough
- 128 committee members is small enough that the on-chain registry is the entire ground truth. No DHT-style scalability is needed.
- Sentry node pattern (next section) hides committee identities from public peers anyway — the committee discovery layer is private.
- Layered fallback means no single point of failure: seeds, DNS, on-chain, PEX, cache.
What's stored in the layered cache
| Layer | Persistence | Trust model |
|---|---|---|
| Hardcoded seeds | binary | Chain-spec trusted |
| DNS records | DNS TTL | DNS operator trusted |
| On-chain registry | JMT | Consensus-finalized |
| PEX cache | LRU 1024 | Peer-attested only |
| Local good-peer cache | disk LRU 100 | Empirically known good |
12.6 Connection Limits and Rate Limiting
crates/net/src/config.rs defaults:
| Constant | Default | Meaning |
|---|---|---|
DEFAULT_PORT | 30303 | Default UDP listen port |
DEFAULT_MAX_PEERS | 50 | Total connected peers |
DEFAULT_MAX_INBOUND | 30 | Max inbound connections |
DEFAULT_MAX_OUTBOUND | 20 | Max outbound connections |
DEFAULT_RATE_LIMIT_PER_IP | 5 / sec | Inbound connect rate per IP |
DEFAULT_IDLE_TIMEOUT | 60 s | Drop idle connections after |
The peer manager (crates/net/src/peer.rs) tracks these per-IP
counters; can_accept() enforces them.
Token-bucket rate limits
The DDoS subsystem (crates/net/src/ddos.rs) implements per-peer
token-bucket rate limiting:
#![allow(unused)] fn main() { RateLimiter { max_tokens: f64, refill_rate: f64, // tokens / sec current: f64, last_refill: Instant, } }
Evidence ingest, in particular, is rate-limited (per the post-Phase-1
audit hardening: task 014d). Without the limit, a non-validator peer
could spam garbage-sig evidence at ~60 µs of FALCON verify each — enough
to consume validator CPU at scale. With the limit, repeat offenders are
dropped after the first failure.
Per-subnet limits
SubnetLimiter (also in crates/net/src/ddos.rs) tracks /24 subnets and
caps connections per subnet, preventing a single network operator from
monopolizing peer slots.
12.7 Peer Reputation
Each PeerInfo (crates/net/src/peer.rs) tracks:
#![allow(unused)] fn main() { struct PeerInfo { peer_id: PeerId, falcon_pubkey: Option<Vec<u8>>, // post-handshake binding role: PeerRole, // Validator / FullNode / Light / Unknown messages_received: u64, invalid_messages: u64, last_seen: Instant, } }
A simple reputation score:
reputation = messages_received - (invalid_messages * 10)
Peers with strongly negative reputation are dropped and rate-limited. The
scoring is deliberately simple — Pyde does not currently ship a
sophisticated gossip score (no peer_score_thresholds), trusting the
combination of validator-channel filtering, FALCON binding, and
token-bucket rate limits to handle the major attack vectors.
A more sophisticated scoring mechanism (decay weights, per-topic scores, gray-listing) is on the post-mainnet hardening list.
12.8 NAT Traversal
Pyde leans on libp2p's standard NAT-traversal tools:
- AutoNAT detects whether the local node is reachable.
- DCUtR (Direct Connection Upgrade through Relay) coordinates QUIC hole-punching between nodes behind cone NATs.
- Relay nodes forward traffic for nodes behind symmetric NATs that can't be hole-punched.
- UPnP / PCP automatic port mapping on supportive home routers.
A node with nat_status = SymmetricNat will rely on relays; a Public
node accepts inbound directly. This is standard libp2p mechanics; Pyde
does not modify the underlying behavior.
12.9 Bandwidth Profile
At the steady-state v1 throughput target (to be established by the multi-region performance harness; ~80 KB average batches, ~500 ms median commit cadence):
| Channel | Inbound | Outbound |
|---|---|---|
| Transactions | ~3 MB/s | ~3 MB/s |
| Batches | ~1 MB/s | ~1 MB/s |
| Consensus (validator) | ~0.3 MB/s | ~0.3 MB/s |
| Sync (serving) | ~2 MB/s | ~2 MB/s |
| DHT / discovery | ~0.1 MB/s | ~0.1 MB/s |
| Validator total | ~6 MB/s | ~6 MB/s |
| Full node total | ~4 MB/s | ~4 MB/s |
Recommended links:
| Role | Bandwidth | Connections |
|---|---|---|
| Validator | 100+ Mbps symmetric | 50–100 |
| Full node | 100 Mbps symmetric | 30–60 |
| Light client | 1 Mbps | 3–5 |
These are well within commodity hosting tiers — no datacenter requirement.
Bandwidth reductions
- Transaction batching within gossipsub (configurable batch + 50 ms flush window).
- Compact blocks for large block bodies — short tx IDs (6 bytes of Poseidon2 hash) instead of full tx hashes (32 bytes).
- LZ4 / Snappy compression on gossip payloads (~60% reduction on transaction batches).
- Mesh dedup cache —
duplicate_cache_time = 60 sprevents the same message from being forwarded multiple times.
12.10 Network Initialization Sequence
On `pyde run`:
1. Load config (TOML); apply CLI overrides.
2. Initialize logging.
3. Create or load validator identity (FALCON keypair if validator).
4. Open RocksDB state store; apply genesis if empty.
5. Attach the consensus_store (restore seen_proposals / votes / evidence).
6. Generate libp2p keypair (Ed25519); derive PeerId.
7. Bind QUIC listener on configured port (default 30303).
8. Connect to bootstrap peers.
9. Run Kademlia FIND_NODE(self) to populate routing table.
10. Subscribe to gossipsub topics.
11. If validator role:
a. Announce committee membership on DHT (validator:{epoch} key).
b. Run FALCON handshake with discovered validators.
c. Start the consensus loop.
12. Start RPC server (HTTP + WebSocket).
13. Start metrics endpoint (Prometheus, default port 9090).
12.11 Metrics
Every node exposes a Prometheus endpoint with at minimum:
| Metric | Type | Meaning |
|---|---|---|
pyde_peers_connected | gauge | Total connected peers |
pyde_peers_by_role | gauge | Validators / full / unknown |
pyde_gossip_messages_received | counter | Messages received per topic |
pyde_gossip_messages_sent | counter | Messages sent per topic |
pyde_bandwidth_inbound_bytes | counter | Total inbound bytes |
pyde_bandwidth_outbound_bytes | counter | Total outbound bytes |
pyde_block_propagation_time_ms | histo | Time from propose to receipt |
pyde_consensus_msg_latency_ms | histo | Round-trip on consensus channel |
pyde_dht_routing_table_size | gauge | Kademlia routing table entries |
pyde_falcon_handshakes_completed | counter | Successful peer handshakes |
pyde_falcon_handshakes_failed | counter | Verification failures |
These feed into the docker/grafana dashboards that ship with the repo.
12.12 Sentry Node Pattern (Committee Defense)
Committee validators have stake at risk and produce vertices on a tight ~500ms cadence — losing connectivity for a few rounds risks liveness penalties. To insulate them from direct attack, Pyde supports the sentry node pattern (Cosmos-style):
Internet
|
v
+----------+ +----------+ +----------+
| Sentry 1 | | Sentry 2 | | Sentry 3 | (public-facing, no stake)
+----+-----+ +----+-----+ +----+-----+
| | |
+-------------+--------------+
| (private VPN/cloud network)
v
+-------------+
| Committee | (hidden, never directly addressable)
| Validator |
+-------------+
- Committee validator only accepts QUIC connections from its known sentry IPs. Public peers never know its IP.
- Sentry nodes are full nodes that route traffic to the validator. They run no stake; if attacked, they're disposable.
- PEX-suppressed — the committee validator does not gossip its address via PEX, so its IP doesn't leak through the discovery layer.
The pattern is supported in pyde.toml:
[network]
sentry_mode = true # for committee validators
allowed_inbound_peers = [
"/ip4/10.0.1.5/udp/30303/quic-v1/p2p/12D3Koo...", # sentry 1
"/ip4/10.0.1.6/udp/30303/quic-v1/p2p/12D3Koo...", # sentry 2
]
suppress_pex_advertisement = true
Non-committee validators and full nodes typically don't bother with sentries.
12.13 What's Out of Scope for Mainnet
Honest about what is not in the network layer at launch:
- Witness delivery to provers. The chain doesn't have provers, so
there's no
pyde/witnesses/1channel. - Erasure coding for vertex propagation. The current implementation fans out vertices via gossipsub. Reed-Solomon erasure coding for very large vertices is on the post-mainnet improvements list.
- Algebraic FALCON batch verification. Implemented as sequential loop for v1; algebraic batching (sharing FFT work across signatures) is post-mainnet hardening.
Summary
| Component | Choice |
|---|---|
| Transport | libp2p over QUIC (TCP fallback) |
| libp2p identity | Ed25519 (PeerId routing only) |
| Application identity | FALCON-512 (vertex sigs, attestations, evidence) |
| Channels | 5 — vertices / transactions / batches / sync / evidence |
| Validator channel filter | FALCON pubkey ∈ current committee |
| Gossipsub mode | Permissive + flood_publish = true |
| Heartbeat | 150 ms (matches DAG round cadence) |
| Mesh size | 8 (low 4, high 12) |
| Peer handshake | FALCON-512 attestation; binds peer_id → falcon_pk |
| Discovery | Layered: seeds → DNS → on-chain registry → PEX → cache (no DHT) |
| Committee defense | Sentry node pattern (Cosmos-style) |
| Connection limits | 50 total / 30 inbound / 20 outbound (defaults) |
| Rate limit (per IP) | 5 / sec (defaults) |
| Symmetric encryption | TLS 1.3 inside QUIC |
| Bandwidth (committee) | 500 Mbps, scales with throughput (Ch 19) |
The next chapter covers the cross-chain and parachain story — what's in scope for mainnet, what isn't, and what the SDK direction looks like.
Chapter 13: Parachains and Cross-Chain
This chapter covers two distinct (and sometimes conflated) topics:
- Pyde's parachain framework — the v1 mechanism for app-specific execution contexts that run as WebAssembly modules with their own state subtrees, their own governance, and their own validator sets opting in from Pyde's main committee.
- Cross-chain bridges to other L1s — the post-mainnet path to interoperability with Ethereum, Bitcoin, and other chains.
These are different things. A Pyde parachain is an on-chain WASM module with extra privileges (its own state space, cross-parachain messaging, threshold-crypto access). A cross-chain bridge is infrastructure that ferries proofs between Pyde and a foreign chain.
For parachains: the framework ships at v1 — the on-chain registry, governance, lifecycle, and execution environment are all part of mainnet. Authors write parachain logic in any wasm32-target language (Rust, AssemblyScript, Go, C/C++) and deploy via the otigen toolchain. The full design is in memory/parachain-v1-design and the upcoming PPIPs (Pyde Parachain Improvement Proposals).
For cross-chain bridges: the surface ships at v1; the implementation ships post-mainnet. The cross_call host function, the HardFinalityCert primitive, and the unified gas model are all available at genesis so contracts can be written today against the interface. The actual cross-chain transports (FALCON-in-EVM verifier, light-client contracts, relay infrastructure) ship after mainnet stability is proven.
13.1 What Mainnet Ships
At mainnet, Pyde does not ship:
- A native bridge to any other chain (no Ethereum bridge, no Bitcoin bridge, no IBC channel).
- Native cross-chain message passing to foreign L1s at the protocol level
(the
cross_callinterface exists; the transports do not). - Slot auctions or Polkadot-style shared-security parachains. Pyde's parachain model is different — see §13.5.
What it does ship:
- A sovereign L1 with the full execution model (WASM contracts via wasmtime, encrypted mempool, FALCON-quorum finality, JMT state).
- The parachain framework (registry, governance, lifecycle, execution environment) — see §13.5 below.
- Hard-finality certificates suitable for use as cross-chain proof inputs by any future bridge contract.
- An architecture that leaves room for cross-chain bridges as post-mainnet extensions.
The reasoning: bridges are the largest historical source of catastrophic loss in the cryptocurrency ecosystem. Shipping a bridge at mainnet without months of audit and incentivized testnet exposure would be irresponsible relative to the launch timeline. A bridge added later, against a stable chain with proven liveness, is a much smaller surface to audit.
13.2 Why Cross-Chain Is Hard
Cross-chain communication boils down to one question: how does Chain B verify that something happened on Chain A? Three families of answers exist:
| Approach | Trust assumption |
|---|---|
| Trusted relay | A multisig or enclave attests to events |
| Light-client proof | Chain B verifies Chain A's consensus signatures directly |
| Validity proof (ZK) | Chain B verifies a SNARK/STARK of Chain A's execution |
Trusted relays are the cheapest to build and the worst by every other metric — every major bridge exploit (Wormhole, Ronin, Nomad, Multichain) hit a trusted-relay design. Light-client proofs require Chain B to implement Chain A's signature verification on-chain, which is expensive but honest. Validity proofs are the strongest model and the most complex to implement.
For Pyde's eventual bridges, the design constraints are:
- No new trusted parties. No multisig "guardians" sit between Pyde and the counterparty chain.
- Light-client verification. The counterparty chain runs a Pyde light client (FALCON verification + finality cert) that proves "block N was hard-finalized on Pyde."
- Symmetric or asymmetric, but always verifiable. If the counterparty chain has its own light-client logic implementable on Pyde, the bridge is symmetric. If not (e.g., Bitcoin), Pyde verifies the counterparty one direction only.
None of this work is on the mainnet critical path.
13.3 Hard-Finality Certificates as Bridge Inputs
The one piece of cross-chain infrastructure mainnet does ship — implicitly — is the hard-finality certificate (Chapter 6):
#![allow(unused)] fn main() { struct HardFinalityCert { wave_id: u64, blake3_state_root: Hash, poseidon2_state_root: Hash, voter_bitmap: u128, // 128-bit bitmap signatures: Vec<FalconSignature>, // ≥ 85 } }
This certificate, signed by ≥ 2f+1 = 85 of the active committee, is exactly the input a future light-client bridge needs:
- A counterparty bridge contract holds the active committee's FALCON public keys (refreshed at epoch boundaries).
- To accept a Pyde-side event, the bridge requires:
- A
HardFinalityCertfor the commit that included the event. - A Merkle proof from the wave's
blake3_state_root(native) orposeidon2_state_root(ZK-circuit-friendly) to the event's storage slot.
- A
- Verification is
(85 × FALCON_verify) + (one Merkle path verification), feasible on any chain with a reasonable VM.
The committee size of 128 caps the per-cert verification cost at ≥ 85 FALCON verifies. At ~1 ms per verify on commodity hardware, that's ~85 ms of counterparty execution per accepted Pyde event — non-trivial but not catastrophic.
13.4 The cross_call Host Function
Cross-context invocation in Pyde is exposed as a WASM host function:
#![allow(unused)] fn main() { // From the WASM contract author's perspective (Rust example): let result = pyde::cross_call( target_address, // contract or parachain address "request_price", // function name &args, // serialized arguments CallbackSpec { success_method: "on_price_received", error_method: "on_price_failed", max_callback_gas: 100_000, timeout_waves: 100, }, )?; }
The same primitive serves three call shapes:
- Smart contract → smart contract (same chain, fully working at v1). Synchronous if both contracts are in the same wave; asynchronous via callback if execution spans waves.
- Smart contract → parachain (working at v1 once parachain framework is live). Asynchronous; the parachain's committee processes the call and submits a callback transaction with the result.
- Smart contract → foreign L1 (interface available at v1; transport ships post-mainnet). Until the cross-chain transport lands, this returns
NotYetSupportedat runtime — but contract code written againstcross_callto a foreign target compiles and deploys today, ready for when the transport ships.
The host function signature is part of the v1 Host Function ABI specification and is stable at genesis. Contracts written today against the v1 interface continue to work as additional transports come online.
Callback context preserved
Every cross_call carries enough context that the callback can reconstruct what happened:
callback_id(unique per call)original_caller(address that initiated the original transaction)original_fn(function that issued the cross_call)original_args_hash(hash of original args; full args retrievable from the chain log)issued_at_wave(when the call was issued)target(who was called)
On result (success, error, or timeout), the callback handler receives both the result payload and the context. Full audit trail is always preserved.
13.5 The Parachain Framework (v1)
Pyde's parachain framework is not a Polkadot-style slot-auction model and is not a separate operator network running off-chain. It is an on-chain execution mechanism for app-specific WebAssembly modules with extra capabilities relative to ordinary smart contracts.
The distinction matters because the "parachain" word is overloaded in the L1 ecosystem. In Pyde:
- Smart contracts are WASM modules with the standard host-function ABI. They share Pyde's state space, follow Pyde's transaction lifecycle, are scheduled by Pyde's main executor.
- Parachains are WASM modules with an extended host-function allowlist (cross-parachain messaging, threshold-crypto access, governance hooks) and their own state subtree partitioned by
parachain_id[..16]under PIP-2 clustering. They have their own validator committees (subsets of the main Pyde committee that opt in), their own consensus instance (chosen from a preset menu at deploy time), and their own upgrade governance (equal-power voting among their validators).
What ships at v1
The full framework: registration, deployment, lifecycle, upgrade governance, state partitioning, cross-parachain messaging, version history retention, and the host-function ABI surface that parachain WASM is built against.
What v1 does not include (deferred to v2 or later):
- A maintained per-language SDK (per the no-SDK approach: authors compile their own WASM in any wasm32-target language using the published Host Function ABI; canonical example projects are provided as starting points, but there is no per-language SDK to maintain).
- ZK-aggregated signature verification for parachain committees (the path to massively higher throughput; v2/v3 work).
Parachain deployment
Authors deploy a parachain the same way they deploy a smart contract — via the otigen toolchain:
otigen init chainlink --lang rust --type parachain
# ... author writes parachain logic in src/lib.rs ...
# ... declares state schema, consensus preset, slashing preset in otigen.toml ...
otigen build
otigen deploy --network testnet
The name is taken from [contract].name in otigen.toml (set at otigen init time); otigen deploy has no --name flag. otigen.toml for a parachain extends the smart-contract schema with parachain-specific fields:
[contract]
name = "chainlink"
type = "parachain"
[parachain]
consensus_preset = "simple_bft" # or "threshold" or "optimistic"
min_validators = 7
quorum_threshold = "2/3"
[parachain.slashing]
preset = "standard" # minimal / standard / strict
# parachain host fns (send_xparachain_message, threshold_decrypt, etc.) are
# auto-allowed by `type = "parachain"`; no per-import declaration needed.
Parachain governance
Parachain upgrades go through equal-power voting among the parachain's validators (one validator, one vote — NOT stake-weighted). Configurable quorum, configurable threshold, with a default 2/3 supermajority. Owner-only emergency pause and kill are available for operational lifecycle. Governance can claw back squatted names via PPIP if the dispute warrants.
Full upgrade history is retained on-chain forever. Every transaction receipt records (parachain_id, parachain_version, wasm_hash) so historical replay can fetch the exact WASM binary that originally executed each tx.
Cross-parachain messaging
Parachains can call each other via the send_xparachain_message host function. Rate-limited, threshold-signed (the calling parachain's committee signs the outgoing message; the receiving parachain's committee verifies it), and routed through Pyde's main consensus as regular transactions. The full mechanism is documented in the upcoming PPIPs.
Why this model rather than slot auctions
Slot auctions (Polkadot-style) concentrate parachain rights in deep-pocketed operators, creating political and centralization risk. Pyde's parachain model is closer to "deploy a contract that happens to have its own state space and validator committee" — anyone can deploy, costs are predictable (ENS-style name registration + owner deposit), and economic alignment is via stake and slashing rather than auction proceeds.
13.6 Native Bridges (Post-Mainnet)
Beyond a parachain SDK, the longer-term direction includes purpose-built bridges to specific chains.
| Direction | Mechanism | Difficulty |
|---|---|---|
| Pyde → Ethereum | Ethereum contract verifies Pyde finality certs | Moderate (FALCON in EVM) |
| Ethereum → Pyde | Pyde contract verifies Ethereum execution proofs | Moderate (Merkle Patricia) |
| Pyde → Bitcoin | SPV-style proofs of Bitcoin finality | Hard (PoW finality is probabilistic) |
| Pyde → other PoS L1s | Each side verifies the other's signature scheme | Variable |
The Ethereum bridge is the most concrete near-term target post-mainnet. The work splits into:
- An Ethereum-side contract that verifies FALCON signatures and
HardFinalityCertstructures. FALCON-512 verification in EVM is non-trivial (algebraic operations over a 12,289-mod ring) but not fundamentally blocked. - A Pyde-side contract that verifies Ethereum execution proofs (Merkle Patricia paths). This part is straightforward — WASM contracts on Pyde can implement Patricia path verification just as Solidity contracts can.
- A relay process that ferries finality certs and execution proofs between the two chains. The relay is permissionless — anyone can run it, and anyone can verify the outputs.
No mainnet timeline commitment exists. The bridge is contingent on:
- Pyde mainnet stability (Phase 9 + Phase 10 of the launch plan).
- Independent audit of the FALCON-in-EVM verifier (probably the most novel piece of crypto code in the bridge stack).
- A specific use case that justifies the bridge (e.g., bringing Ethereum-issued stablecoins to Pyde at scale).
13.7 What WASM Contracts Can Do Today (No Bridge)
A few cross-chain-adjacent things are still possible at the application layer without any protocol-level bridge:
Off-chain oracle pattern
A contract on Pyde that needs an external value (e.g., an asset price) can:
- Define an
oracle: Addressstorage field. - Allow only that address to write to a
pricesmap. - The "oracle" is an off-chain process running by some trusted operator (or a multisig) that submits update transactions.
This is not a bridge. It is a trusted off-chain feed. But it works, and it unlocks DeFi applications without waiting for a bridge.
Mirror tokens
A token contract on Pyde can represent off-chain assets (USDC, ETH-pegged) by trusting a multisig minter. This is the same trust model as wrapped tokens on every other chain — appropriate when the operator is sufficiently trusted (e.g., a regulated custodian) but not appropriate as a default bridge.
Light-client deployments
If a developer wants to verify Ethereum events on Pyde today, they can deploy an Ethereum-light-client WASM contract that consumes Ethereum block headers (relayed by an off-chain process) and verifies execution proofs against them. The verification work is done by the contract; the relay is just data ferrying.
This is the right pattern, even if the relay is operationally trusted — the verification is on-chain and trustless.
13.8 Parachain Economics
A common question: what does PYDE pay for in a parachain world?
PYDE is the gas token across the platform. Every parachain operation that touches state, emits events, sends cross-parachain messages, or consumes execution gas is metered in PYDE via wasmtime fuel — exactly the same as smart-contract operations. Authors pay registration fees + owner deposits in PYDE at deploy time. Validators of a parachain earn PYDE rewards via the standard inflation distribution, weighted by their committee membership and uptime.
Parachain authors can layer their own internal token economies on top (e.g., a DEX parachain might mint LP tokens; a DAO parachain might mint governance tokens) — but those are application-layer concerns, not protocol-level mechanics. The protocol charges PYDE; what the parachain charges its users is its own decision.
This keeps the gas accounting simple: one token, one fuel mechanism, uniform across smart contracts and parachains.
13.9 What the Plan Looks Like
| Stage | Cross-chain capability |
|---|---|
| Mainnet (v1) | Parachain framework live (WASM-based); cross_call host function available; HardFinalityCert format stable |
| Post-mainnet — Stage 1 | First production parachains deployed (DEX, oracle, etc.) |
| Post-mainnet — Stage 2 | First Ethereum bridge (FALCON-verifier on EVM + Pyde-side Patricia verifier) |
| Post-mainnet — Stage 3 | Multi-chain bridges (additional foreign L1s) |
| Post-mainnet — Stage 4 | ZK-aggregated FALCON signatures (reduces bridge verification cost dramatically) |
| Post-mainnet — Stage 5 | zk-WASM proven execution (where research is heading) |
These are directional. Each stage is gated on the maturity of the previous stage and on credible auditor capacity, not on a calendar.
Summary
| Capability | At mainnet? | Post-mainnet plan? |
|---|---|---|
| Sovereign L1 | Yes | — |
| Hard-finality certificate (cert format) | Yes | Used by future bridges |
| Parachain framework (WASM-based) | Yes | Production parachains roll in over time |
| Cross-parachain messaging | Yes (with framework) | Optimizations + ZK aggregation |
cross_call host function (interface) | Yes | Foreign-chain transports wired post-mainnet |
| Smart-contract → smart-contract calls | Yes (working) | Performance optimizations |
| Smart-contract → parachain calls | Yes (with framework) | — |
| Smart-contract → foreign L1 calls | Interface only, returns NotYetSupported | Wired when bridges ship |
| Native bridge to Ethereum | No | Yes (FALCON-in-EVM) |
| Native bridge to Bitcoin | No | Maybe (SPV proofs) |
| Off-chain oracle / multisig mints | Possible at app layer | Same as today |
| Light-client contracts (Ethereum) | Possible at app layer | Easier with bridge |
Pyde at launch is a sovereign network with a working parachain framework, designed not to depend on cross-chain bridges. Sovereign assets, sovereign users, sovereign apps, sovereign parachains. Foreign-chain bridge work begins once that base is provably stable.
The next chapter covers the PYDE token: supply, inflation, distribution, fee mechanics, and staking economics.
Chapter 17: Developer Tools
Pyde's developer toolchain is the set of command-line programs, SDKs, and RPC endpoints that let people write, test, deploy, and interact with contracts. This chapter is the reference survey — what exists, what it does, where to find it.
For deep documentation on the primary developer-facing tool (the otigen binary), see Chapter 5: Otigen Toolchain. This chapter does not duplicate that material; it summarizes and points outward.
What's in scope
otigen— the developer toolchain binary. Handles project scaffolding, builds in any supported language, state binding generation, deployments, wallet management, REPL access, and an embedded chain runtime for one-command local devnets. The single tool most contract developers use day-to-day.pyde-rust-sdk— the Rust client SDK for talking to a Pyde node programmatically.pyde-ts-sdk— the TypeScript / JavaScript client SDK.pyde-crypto-wasm— WASM bindings exposing post-quantum cryptography (FALCON signing, Kyber encryption, Poseidon2/Blake3 hashing) to browser and Node.js environments.
A standalone pyde node binary (light / full / validator profiles) is planned post-public-testnet. For v1, the chain runtime lives inside otigen and is reached via otigen devnet.
What's not in scope at launch (tracked later)
- A dedicated Pyde block explorer frontend (the backend indexer is planned; the UI is community ecosystem work).
- A proprietary IDE. Standard editors with the language's standard tooling (rust-analyzer for Rust, the AssemblyScript LSP, gopls for Go, clangd for C/C++) are the intended path. No Pyde-specific IDE.
- Per-language testing wrappers for pure helpers. Authors use their language's native test runner (
cargo test,npm test,go test,clang+ their test framework of choice) for function-internals tests. Contract behaviour tests — state changes, events, reverts — go throughotigen test, a Foundry-style TOML-driven runner shared across all four supported languages. See §17.1 below and Chapter 5 §5.12 for the split.
17.1 otigen — the developer toolchain
The Cargo-equivalent build-and-deploy toolchain for Pyde. Replaces the earlier wright toolchain that targeted the now-retired Otigen smart-contract language.
otigen is language-agnostic: the same binary handles projects authored in Rust, AssemblyScript, Go (via TinyGo), or C/C++. Authors declare their language in otigen.toml; otigen invokes the correct compiler with the correct WASM target and packages the resulting artifact for deployment.
Subcommand summary
otigen new <name> --from <template> Clone a canonical template (8 ship: counter, erc20-token, erc721-token,
simple-multisig, upgradeable-proxy, merkle-claim-airdrop, vesting,
dao-governance). `otigen new --list` enumerates them.
otigen init <name> --lang <language> Scaffold a new project (--type contract|parachain selects the surface)
otigen build Build the WASM module + ABI + bundle artifact
otigen check Validate without packaging (fast CI gate)
otigen deploy Sign and submit a deploy transaction (--rpc-url + --chain-id one-shot override)
otigen upgrade Lifecycle ladder — refused at the CLI in v1 (EngineNotReady; chain has no
TxType::Lifecycle handler). Bypass for stub-engine testing: --i-know-engine-rejects.
v1 pattern: proxy + delegate_call.
otigen pause / unpause / kill Same lifecycle gate. v1 pattern: author-declared `paused`/`killed` booleans in [state].
otigen call <addr-or-name> <fn> Invoke a function (view mode is free; --from switches to signed state-mutating tx)
otigen inspect <addr-or-name> Read account snapshot + ABI summary; --state-field reads typed scalar storage;
--field reads legacy raw slots; --rpc-url one-shot override; --at-wave on archive nodes
otigen verify <addr-or-name> Compare local bundle against chain-stored bytes
otigen validator <subcmd> Read-only validator-introspection: `show <addr>` / `by-operator <addr>`
otigen wallet Wallet management (new / list / show / import / delete / password / export / sign / verify)
otigen test Run contract behaviour tests (tests/*.test.toml) — wasmtime sandbox per test with
mocked `pyde::*` host fns by default; --no-engine for the legacy in-process mock
otigen devnet Run a local devnet — chain runtime is embedded in `otigen` (no separate `pyde` download)
otigen console REPL against a Pyde node — balance / nonce / state / events / call / tx
The two test layers complement each other:
cargo test/npm test/go test(the author's language-native runner) — pure helpers, math, parsing, formatting. Runs in-process, microseconds per test, no chain semantics.otigen test— contract behaviour. Spins up a wasmtime sandbox per test, mocks everypyde::*host function, drives the contract through TOML-declared scenarios with named accounts, named storage slots, time / wave / chain cheats, multi-call sequences, named event matching, and revert assertions. The same.test.tomlruns against the contract regardless of source language. Spec:OTIGEN_TEST_SPEC.md.
Performance — what to expect from otigen build
The whole otigen build validation + packaging pipeline runs in single-digit microseconds of CPU work for a typical contract (parse otigen.toml, validate every cross-cutting rule, walk the compiled .wasm for imports + exports + deterministic-feature compliance, build the canonical ContractAbi, Borsh-encode, inject the pyde.abi custom section). Wall-clock invocations are dominated by file I/O — reading the .wasm + writing the four bundle files — which lands in the 1–5 ms range on commodity hardware. Validator work is essentially free against that.
The full in-memory pipeline measures ~14.5 µs on an Apple M-series reference machine. Per-step numbers (Blake3 selector derivation, Borsh encode, custom-section injection, WASM-feature validation) are in Chapter 5 §5.11 with a reproduction recipe via cargo bench. Baselines are committed under crates/<crate>/benches/baseline/ in the pyde-net/otigen repo; future regressions surface on every PR that runs cargo bench --baseline=v1.
For the full reference — otigen.toml schema, per-language workflows, state binding generation, deploy/upgrade flow internals, performance numbers — see Chapter 5.
17.2 The engine workspace and otigen devnet
There is no separate pyde node binary at v1. The chain runtime — the execution layer (wasmtime + Cranelift AOT), the JMT state layer (PIP-2 clustering, dual-hash, PIP-3 prefetch, PIP-4 write-back cache), the mempool, and the JSON-RPC server — lives in the pyde-net/engine workspace as a library, and ships embedded inside the otigen binary so authors get a one-command devnet:
otigen devnet One-command local devnet. Spins up the embedded engine, pre-funds 10 deterministic accounts
(`Blake3("pyde-devnet-v1/" || i)`), exposes JSON-RPC on 127.0.0.1:9933 (and `/ws` for
subscriptions). On Ctrl-C, all state is wiped. No config, no separate download.
otigen validator show <addr> and otigen validator by-operator <addr> provide read-only introspection over the chain-side ValidatorRecord; they're operator queries, not validator-mode flags.
The standalone validator surface — long-lived validator process, light/full/validator profiles, key rotation, stake management, genesis-manifest tooling — is post-public-testnet work and will ship as a separate pyde binary. v1 does not exercise those code paths from a CLI; they're library entry points in the engine workspace today. A full operational reference for validators is published separately (see Validator Operating Guide, post-public-testnet).
17.3 SDKs
Two first-class language SDKs at launch, with the WASM crypto bindings as a third-party-friendly bridge.
pyde-rust-sdk
Idiomatic Rust client for Pyde nodes. Use cases:
- Backend services interacting with Pyde from Rust applications.
- Scripted deployment + interaction (alternative to
otigen's deploy/send commands when scripting in Rust). - Tools building on top of Pyde (indexers, monitoring, custom validators).
Surface area:
- Transaction construction + signing
- RPC client (JSON-RPC over HTTP and WebSocket)
- Streaming subscriptions (new waves, account changes, event filters)
- ABI encoding/decoding helpers
- Wallet integration (load keys from
~/.pyde/keystore.json, hardware wallets via external signer protocol)
pyde-ts-sdk
TypeScript / JavaScript SDK. Ships at ethers-equivalent maturity from day one (lessons from EVM tooling baked in).
Surface area:
- Same primitives as
pyde-rust-sdkbut idiomatic TS - Browser-friendly via tree-shaking + WASM crypto bridge
- Type-safe ABI generation from
abi.jsonartifacts - Wallet adapter pattern for browser-wallet integration
Pure-language SDK like ethers v6 — no React / Vue / Svelte / wagmi-style hooks. Framework adapters are out of scope for this package and ship (if at all) as separate companion packages so the core SDK stays small and framework-neutral.
pyde-crypto-wasm
WASM bindings exposing post-quantum cryptography to JavaScript. Used internally by pyde-ts-sdk, also usable directly by any project that needs PQ crypto in a browser or Node.js environment.
Surface area:
- FALCON-512 keypair generation, sign, verify
- Kyber-768 encryption / decryption
- Threshold-encryption shares (where used by client-side encrypted-tx submission)
- Poseidon2 and Blake3 hashing
Contract-side SDKs (community)
The SDKs above are client-side — they let backends, scripts, and front-ends talk to a Pyde node. Writing the contract itself is the other side of the boundary, and that's where the per-language community SDKs come in.
Pyde Network ships one canonical contract-side SDK — the Rust stack in pyde-net/otigen (pyde-host, pyde-storage-macros, pyde-entry-macros). Bringing your language to that surface is a community pathway: the chain holds a stable WASM ABI (HOST_FN_ABI_SPEC) and a stable bundle format (OTIGEN_BINARY_SPEC); everything above is open to any language that targets wasm32-unknown-unknown.
If you're maintaining or proposing a language SDK, the contract you must satisfy lives in:
- SDK Author Guide — the four invariants every SDK must hold (void-void entry signature, borsh-canonical calldata, host-fn signature parity,
pyde.abicustom section), the reference implementation's surface, and the quality bar to ship. examples/storage-stressin otigen — the canonical acceptance contract. A community SDK is "ready" when its port of the 28-assertiontests/stress_e2e.pypasses end-to-end againstpyde devnet.
Community SDKs publish under their own org (e.g., pyde-go/, pyde-ts-contracts/) and are listed back here by PR against pyde-net/pyde-book. No SDK is currently in the listing — this section will fill in as language communities ship.
17.4 JSON-RPC
The node exposes a JSON-RPC interface over HTTP and WebSocket. Method surface includes:
- Standard query methods —
pyde_getAccount,pyde_getBalance,pyde_getTransactionCount,pyde_getContractCode,pyde_getStorageSlot,pyde_resolveName - Transaction submission —
pyde_sendRawTransaction,pyde_sendRawEncryptedTransaction,pyde_estimateAccess - View-function calls —
pyde_call(contract, fn, calldata)— free, off-chain execution against current state; no tx, no gas, no consensus. Mirrors EVM'seth_call. Bounded by RPC-layer rate limits + per-call instruction cap. - Archival reads (full + archive nodes) —
pyde_getTx(hash),pyde_getReceipt(hash) - Subscription methods (WebSocket on
/ws) —pyde_subscribe:newHeads— wave commits as they finalizeaccountChanges— state changes to a specific accountlogs— events matching an AND+OR filter (topic OR-list + optional contract); at-least-once delivery; each event carries(wave_id, tx_index, event_index)cursor for dedup;pyde_resubscribe({from: cursor})resumes after disconnect. Full mechanics: Host Function ABI Spec §15.5.
- Historical event queries —
pyde_getLogs({from_wave, to_wave, topics, contract, cursor, limit})— 5,000-wave cap per request, cursor pagination, ascending wave order. Per-wave bloom filter prefilters; three RocksDB indexes resolve exact matches. Full spec: Host Function ABI Spec §15.4. - Gas / fee estimation —
pyde_estimateGas,pyde_getBaseFee
Wire-shape quirks the SDK tolerates (transaction-type strings, byte-array addresses on archival reads, getTransactionCount snapshot lag, devnet rate-limiting) are catalogued in the SDK companion guide. The canonical method catalog is published as the JSON-RPC reference alongside the engine workspace.
17.4b Client-Side wasmtime + Wallet Preview Tiers
Pyde's TS and Rust SDKs embed wasmtime directly, so wallets can simulate transactions locally before signing. This unlocks honest pre-sign safety information without server-side round trips.
Tier 1 — Deterministic local preview (v1 mainnet)
The default. Wallets ship with:
- Gas estimation — run the tx against current state locally; count consumed fuel; show user the expected gas cost
- Access list inference — speculatively execute; record every sload/sstore call's slot_hash; attach the inferred access list to the tx so the chain can warm its execution cache via PIP-3 multiget prefetch before Block-STM workers start
- View function execution —
view-attributed functions execute locally, fetching state via RPC for any cache misses; no tx submitted, no gas - Dry-run preview — show the user "this tx will spend X PYDE, transfer Y tokens to address Z, emit Transfer event, leave your balance at W"
- Known-pattern decoding — recognize standard ABI patterns (transfer, approve, etc.) and surface them in plain language
The user clicks Sign only after seeing exactly what the tx does in this moment.
Wallet UX flow (Tier 1):
User constructs tx in wallet
↓
Wallet fetches contract WASM + relevant state via RPC
↓
Wallet runs wasmtime locally with the tx
↓
Wallet displays preview:
"Calling Token.transfer(to=0xabc..., amount=100 PYDE)
This tx will:
- Send 100 PYDE from you (0xYOU) to 0xabc...
- Your balance after: 900 PYDE
- Emit event: Transfer(from=0xYOU, to=0xabc..., amount=100)
- Cost: ~25,000 gas (~0.001 PYDE)
[Sign] [Cancel]"
↓
User signs (FALCON-512) → tx submitted
Tier 2 — Reputation + heuristics (v2 direction)
Layers on top of Tier 1. Doesn't require AI — just curated data + pattern matching:
- Flag contracts on known-malicious lists (Blockaid, Pyde-community-maintained registries)
- Flag "approve unfamiliar contract for max amount" patterns
- Cross-reference with audit databases (was this contract audited? by whom?)
- Surface community reputation scores
Tier 3 — LLM-augmented analysis (v3+ direction)
LLM reads contract WASM (or decompiled source) to summarize behavior, identify common risk patterns:
- approve+drain combos
- hidden auth modifiers
- timelocked backdoors
- liquidity-rug constructions
Rates confidence: "looks like a standard DEX trade" vs "matches wallet-drainer pattern X." Surfaces a graded warning to the user.
By the time Pyde mainnet matures, third-party services (Blockaid, Pocket Universe, etc.) will likely offer this as an API. Pyde wallets can integrate.
Honest v1 framing
The marketing claim Pyde v1 can make:
Pyde wallets show you the immediate effects of every transaction before you sign — including exact state changes, events emitted, and gas cost. You see what your authorization does in this moment. Deeper analysis (downstream authorization implications, contract backdoors, signed-message replays) requires reading the contract code or using third-party safety tools.
Honest, defensible, materially better than EVM wallet UX without overpromising.
What Tier 1 cannot detect
Worth being explicit about:
- Approval-then-drain patterns. The approval looks innocuous (just a state write). The drain happens in a future tx that the malicious contract submits using that approval.
- Time-locked backdoors. Contract logic that activates after N waves.
- Signed-message replay. Signing arbitrary EIP-712-style messages off-chain that can be replayed.
These are application-layer risks. Tier 2/3 (when shipped) address them. v1 documents them honestly so users know to use third-party tools for those classes of analysis.
17.5 What changed at the pivots
For readers coming from the pre-pivot world, the developer tooling has changed substantially:
| Pre-pivot (Otigen-language era) | Post-pivot (current) |
|---|---|
otic — Otigen compiler | Retired; archived |
wright — project CLI | Retired; archived. Role taken by the new otigen binary |
.oti source files | Replaced by author's language of choice (.rs, .ts, .go, .c) |
| PVM bytecode artifacts | Replaced by WASM .wasm artifacts |
| Otigen-specific tests | Two layers: author's language-native test runner for pure helpers (cargo test, etc.) + otigen test for contract behaviour (TOML-declared, language-agnostic) |
pyde.toml config | Replaced by otigen.toml config with state schema declaration |
The otigen name survives, repurposed for the developer toolchain. See The Pivot for the full narrative, and pivot/02-otigen-language-era.md for the design record of the retired language.
17.6 Where everything lives
| Tool | Repo |
|---|---|
otigen developer toolchain (includes embedded chain runtime via otigen devnet) | pyde-net/otigen |
| Engine workspace (execution layer, JMT state, mempool, JSON-RPC) | pyde-net/engine |
pyde-rust-sdk | pyde-net/pyde-rust-sdk |
pyde-ts-sdk | pyde-net/pyde-ts-sdk |
pyde-crypto-wasm | pyde-net/pyde-crypto-wasm |
Archived otic compiler | pyde-net/otic (archived) |
Archived wright toolchain | pyde-net/wright (archived) |
| The Otigen Book (historical) | pyde-net/otigen-book (preserved as historical artifact) |
17.7 Reading on
- Chapter 5: Otigen Toolchain — the deep reference for the
otigenbinary. - Chapter 3: Execution Layer — the WASM runtime that compiled contracts execute under.
- Chapter 11: Account Model — the name registry the toolchain interacts with.
- Chapter 18: Protocol Upgrades — how contract and protocol upgrades flow.
- Preface: The Pivot — narrative on why the toolchain looks the way it does.
Chapter 18: Protocol Upgrades
Pyde's upgrade model is the same one Bitcoin and Ethereum use: a public specification (PIPs), a reference implementation, and voluntary validator upgrade. Validators choose whether to run a new release. There is no on-chain governance switch that flips protocol rules without that choice.
This chapter covers the upgrade process end to end: the PIP linkage, the validator upgrade flow, hard-fork vs soft-fork distinctions, the emergency pause as a separate mechanism, and the patterns for in-flight state migrations.
18.1 Upgrade Categories
Different changes require different process weight.
| Category | Example | Process required |
|---|---|---|
| Operational config update | Bootstrap node list, log level | Operator-side; no PIP |
| Bug fix (no protocol change) | Memory leak, RPC parse bug | Code release; PIP not required |
| Backward-compatible feature | New opcode unused by existing contracts | PIP + voluntary upgrade; no fork |
| Backward-incompatible (hard fork) | Gas cost change, new tx type semantics | PIP + activation block + coordinated upgrade |
| Cryptographic primitive change | Hash migration | PIP + multi-version overlap window |
| Treasury action | Grant payout, audit funding | PIP + on-chain MultisigTx |
| Emergency response | Active exploit | EmergencyPause (multisig); fix; resume |
Each path has its own velocity. A bug fix can ship in days; a hash migration takes months and dedicated audit time.
18.2 The Voluntary Upgrade Flow
For a typical hard-fork-grade change (e.g., adjusting a gas constant):
Step 1 — PIP draft
Author writes the PIP, opens PR against zarah-s/pips, defines:
- the change
- the rationale
- the activation block height (or activation epoch)
- the test plan
- backward compatibility implications
Step 2 — Discussion + acceptance
Open review by core devs, validators, security team.
PIP merges into pips repo with a final number.
Step 3 — Implementation
Code change merged into the Pyde repo, referencing the PIP #.
Ships in the next node release (e.g. v0.5.0).
Step 4 — Release announcement
The release notes name the activation block.
Typical activation window: weeks to months out, to give validators
time to upgrade.
Step 5 — Validator upgrade
Each validator operator updates their binary. They can do this as
early as they want; the new code is dormant until activation block.
Step 6 — Activation
At the named activation block, every node running the new release
starts using the new rule. Nodes still on the old release either:
- Fork off (if the change is consensus-incompatible).
- Stay in sync (if the change is backward-compatible).
Step 7 — Stable state
After enough time, the upgrade is "settled" — old releases are
deprecated, the network runs the new rule.
There is no on-chain "yes/no" vote. The closest signal is what fraction of the active committee runs the new code at activation. If less than 2f+1 (85 of 128) validators upgrade, the new rule cannot reach finality and the change is effectively rejected by the network.
This is governance through validator opt-in. It is slow and conservative by design.
18.3 Hard Fork vs Soft Fork
Hard fork (consensus-incompatible)
A hard fork is a change that nodes running the old rules cannot accept under any circumstances — e.g., a new gas cost, a new transaction type the old code doesn't recognize, a change to the encryption scheme.
For a hard fork:
- Activation block must be set well in advance.
- Validator coordination is required: at least 2f+1 must be on the new release at activation.
- Validators that don't upgrade fork off; their chain is the legacy version.
- Hard forks should be rare and well-justified.
Soft fork (backward-compatible)
A soft fork tightens the rules — old nodes still accept the new rules (they're a subset of what the old node would accept), but new nodes won't accept blocks that violate the new rules.
For a soft fork:
- Activation can be more gradual; majority of validators on the new release is enough to enforce the new rule.
- Old validators stay in sync; they just don't enforce the new constraint themselves.
- Soft forks are the preferred path when possible.
Simple non-fork
Changes that don't alter consensus semantics — e.g., a new RPC method, a performance optimization, a logging fix — ship in regular releases without any activation block. Operators upgrade at their own pace.
18.4 What Can and Can't Be Changed
Hard-coded (require code release + PIP)
Per Chapter 15:
| Constant | Where |
|---|---|
| DAG round period (~150 ms) | crates/consensus/src/round.rs |
| Commit target (~500 ms) | crates/consensus/src/wave.rs |
| Committee size (128) | crates/consensus/src/committee.rs |
| Quorum / threshold (85) | crates/consensus/src/quorum.rs |
| Equivocation threshold (44) | crates/consensus/src/quorum.rs |
| Validator min stake (10,000 PYDE) | crates/tx/src/pipeline.rs (will move to shared crate post-consensus-rebuild) |
| Operator-identity cap (3 / operator) | crates/tx/src/pipeline.rs |
| Unbonding period (30 days) | crates/consensus/src/validator.rs |
| Inflation schedule | crates/tx/src/fee.rs |
| Fee split (70/20/10) | crates/tx/src/execution.rs |
| Gas target / ceiling | crates/tx/src/fee.rs |
| Tx / calldata size limits | crates/tx/src/validation.rs |
| Max batch size (4 MB) | crates/mempool/src/batch.rs |
| Cryptographic primitives | pyde-crypto polyrepo (FALCON, Kyber, Blake3, Poseidon2) |
| WASM host function ABI | crates/wasm-exec/src/host_fns.rs + Host Function ABI spec doc |
Changing any of these requires a release + voluntary upgrade.
On-chain (multisig-controlled)
| Item | Mechanism |
|---|---|
| Treasury spend | MultisigTx (type 9) |
| Multisig signer set | RotateMultisig (type 10) |
| Emergency pause | EmergencyPause (type 11) |
| Resume from pause | EmergencyResume (type 12) |
These are bounded actions — drain treasury (with PIP linkage), rotate signers, halt for ≤ 30 days, resume. They cannot change protocol rules.
Operator-side
| Item | Lives in |
|---|---|
| Bootstrap peer list | pyde.toml [network] bootstrap_peers |
| RPC endpoint config | pyde.toml [rpc] |
| Log level / format | pyde.toml [logging] |
| Metrics port | pyde.toml [metrics] |
| Datadir location | pyde.toml [node] datadir |
Operators control these per-node; they don't require coordination.
18.5 Emergency Pause as Crisis Response
EmergencyPause (type 11) is not a normal upgrade mechanism — it's a
crisis-response tool. The signer set should be specifically chosen for
crisis response (core devs + security team), with a low threshold so a
quick response is possible.
Workflow during a live exploit:
t=0 Active exploit detected
t+5min Security team confirms; emergency multisig assembles signatures
t+10min EmergencyPause (duration: e.g., 24 hours) submitted on-chain
t+20min Pause takes effect; chain halts non-Resume txs
t+1-24h Fix developed, code-reviewed, audited, released
t+24h EmergencyResume submitted; chain resumes
t+24h Validator operators upgrade to the patched release
The 30-day max pause window (MAX_PAUSE_DURATION_WAVES) is a hard
ceiling — no extension mechanism. If an issue genuinely requires longer
than 30 days to fix, the chain restarts via genesis adjustment plus
voluntary validator upgrade — a much heavier process designed for the
"this can't be fixed in one pause window" case.
18.6 State Migration Patterns
Changes that affect on-chain state require a migration plan. Three common patterns:
Pattern 1: Lazy migration
The old format remains valid; new code accepts both and writes the new format on first touch.
Example: adding a new field to the Account struct.
- Old encoding: 141 bytes
- New encoding: 145 bytes (4 extra bytes for new_field)
- Migration: nodes accept both; on any update to an account, write the
new format.
- Eventually all touched accounts are in the new format. Old untouched
accounts stay in the old format until something writes them.
This works for additive changes that don't break existing readers.
Pattern 2: Activation-block migration
A specific block height where the format flips. Before that block, old format; after, new format.
Example: changing the canonical hash function (hypothetical).
- Activation block N.
- Pre-N: all hashes are Poseidon2.
- Post-N: all hashes are NewHash2.
- Old data continues to be read with Poseidon2 (matches its block height);
new data uses NewHash2.
- State proofs for pre-N data remain valid against pre-N state roots;
post-N data uses post-N state roots.
This is heavyweight. It requires careful protocol-version tracking on every state read.
Pattern 3: Migration transaction
The migration is a tx that anyone can submit; it transforms specific state in place.
Example: per-account vesting schedule format change.
- PIP defines the migration transaction format.
- During an upgrade window, anyone can submit MigrateVesting(account)
transactions that re-write the schedule in the new format.
- After a deadline, the old format is no longer accepted.
Useful when the migration is per-account or per-asset and can be done gradually.
18.7 Versioning Discipline
The Pyde release cadence is release-based, not block-based — releases
ship when ready, not on a fixed schedule. Each release has a semver-style
version (e.g., 0.4.2).
| Component | Version source |
|---|---|
| Node binary | pyde --version |
otigen developer toolchain | otigen --version |
pyde-rust-sdk crate | Cargo.toml version |
pyde-crypto-wasm pkg | package.json |
| Host Function ABI version | embedded in the artifact |
| Contract ABI version | embedded in the artifact |
The binary embeds the wire-format version (EVIDENCE_VERSION = 1 for
slashing evidence, MULTISIG_VERSION = 0x01 for multisig payloads). If
either is bumped, that's a hard fork — the deserializer rejects unknown
versions.
18.8 Coordinating an Upgrade
The day-of-upgrade checklist for a hard fork:
T-30 days: PIP merged, release tagged, activation block announced.
T-14 days: Foundation publishes "validator upgrade tracker" — counts how
many of the active committee have signaled the new release.
T-7 days: If <80% of active committee on the new release, postpone the
activation block via a follow-up PIP.
T-1 day: Final reminder.
T-0: Activation block. New rule takes effect.
T+1 hour: Foundation confirms chain is producing under the new rule.
T+1 week: Old releases marked deprecated.
The "80% signaling threshold" is a social norm, not a protocol enforced threshold. The protocol-enforced threshold is 2f+1 = 85 of 128 — but shipping at exactly 85 is brittle: a single validator going offline mid-flight drops the network below quorum. 80%+ as a social coordination target gives margin above the protocol minimum.
Validator signaling
There is no explicit on-chain signal of "validator X has upgraded." The signaling happens out-of-band:
- Validators announce their version via the
identifylibp2p protocol. - Foundation operates a tracker that polls validators and reports aggregate version distribution.
- Validators may also announce intent in PIP discussion threads.
A more formal on-chain signal (e.g., embedding the running release in each committee member's vertex attestation) is on the post-mainnet improvement list.
18.9 Comparison: Pyde vs Other Upgrade Models
| Property | Pyde | Ethereum | Tezos / Cosmos |
|---|---|---|---|
| Off-chain proposal | PIP | EIP | TIP / CIP |
| On-chain governance vote | None | None at protocol level | Yes (stake-weighted) |
| Validator upgrade | Voluntary | Voluntary | On-chain "self-amendment" |
| Hard-fork coordination | Activation block + social | Activation block + social | Voted on-chain |
| Treasury action | On-chain multisig + PIP | Foundation grants | On-chain (Tezos), proposal (Cosmos) |
| Emergency halt | Multisig pause | None | Sometimes (social fork only) |
Pyde's model is closer to Ethereum / Bitcoin than to Tezos / Cosmos. The trade-off: slower to react than on-chain governance, but no plutocratic-vote attack surface.
18.10 Honest About Limitations
- No on-chain validator-upgrade signal. Coordinated activation depends on out-of-band tracking. A future PIP could add an opt-in signaling-via-vote-payload mechanism.
- No automatic rollback. If a hard fork ships with a critical bug discovered post-activation, recovery requires another release + another upgrade. The emergency pause buys time but doesn't undo state changes.
- Manual genesis adjustment for catastrophic-recovery scenarios is documented but never operationally tested at scale. (The mainnet plan's Phase 9 incentivized testnet is the place where this kind of recovery could be rehearsed.)
- No validator slashing for "voted for the wrong fork." Validators can signal whatever they want; only protocol-level misbehavior (double signing, equivocation, etc.) is slashed.
Summary
| Property | Status at mainnet |
|---|---|
| Upgrade model | PIP + voluntary validator upgrade |
| Hard fork mechanism | Activation block + coordinated upgrade |
| Soft fork mechanism | Same; old nodes stay in sync |
| Treasury action | On-chain MultisigTx + PIP linkage |
| Emergency response | EmergencyPause (≤30 days, auto-expiring) |
| State migration patterns | Lazy / activation-block / migration tx |
| Wire-format versions | EVIDENCE_VERSION, MULTISIG_VERSION (bumped on layout change) |
| On-chain validator-upgrade signal | None (out-of-band tracking) |
| Automatic rollback | None (re-release path) |
The next chapter covers the launch strategy — the ten-phase mainnet plan, the testnet milestones, and the audit + incentivized testnet requirements before mainnet genesis.
Chapter 19: Launch Strategy
This chapter is the road from "code in a repo" to "live mainnet" — the principles and the shape of the path, not the calendar.
There are no specific launch dates in this document. Phasing is honest; calendar commitments are not made.
19.1 Launch Philosophy
Three principles that shape every phase:
-
Audit before stake. Every line of consensus, cryptography, execution, and state-layer code goes through external audit before any user has serious skin in the game. The audit is not a formality.
-
Testnet exposure before mainnet. A multi-month incentivized testnet with reference contracts and external developers must run cleanly before any genesis ceremony. Real network conditions catch issues no simulation does.
-
Voluntary launch. No one is forced onto Pyde mainnet. The genesis validator set is recruited and validated; users opt in by deploying contracts and bridging value.
The plan is conservative on purpose. A delayed launch is recoverable; a botched launch is not. Bridge exploits and broken consensus hard-forks have ended chains.
19.2 The Shape of the Path
The plan groups work into sequenced phases. They are not strictly linear — many items run in parallel within a phase — but each phase has a bar that gates the next.
Summary, in order:
| Phase | Bar |
|---|---|
| Pivot foundations | Documentation, repo cleanup, foundational design specs |
| Engine cleanup | Pre-pivot crates removed from active workspace; archived for reference |
| WASM execution hardening | Single-language end-to-end (contracts deploy, execute, modify state, state verifiable) |
| Multi-language + parachain framework | All supported languages working; parachain governance + lifecycle complete |
| Public testnet | Multi-region committee, external developers building real contracts |
| Audit + stress + bug bounty | External audit complete; all critical findings resolved; stress testing passed |
| Mainnet candidate | Final build; validator set committed; genesis configuration locked |
Each phase's deliverables and exit criteria are tracked to the smallest actionable unit; this chapter covers the shape, not the line-item checklist.
19.3 What ships at mainnet vs after
Pyde mainnet ships with:
- Post-quantum cryptography: FALCON signatures, Kyber threshold encryption, Poseidon2 + Blake3 hashing.
- Mysticeti-style consensus with sub-second median commit and 85-of-128 FALCON quorum certificates.
- WASM execution via wasmtime + Cranelift AOT, with the host-function ABI v1.0.
- JMT state with dual-hash (Blake3 + Poseidon2) per node, PIP-2 clustered keys, PIP-3 prefetch, PIP-4 write-back cache.
- libp2p + QUIC + Gossipsub networking with bootstrap-based peer discovery (no DHT).
- Native multisig accounts; ENS-style name registration for contracts and parachains.
- The
otigendeveloper toolchain with Rust, AssemblyScript, Go (TinyGo), and C/C++ support. - The Rust and TypeScript SDKs.
Mainnet does not ship with:
- Programmable accounts (post-mainnet —
Programmableenum variant reserved at v1 so contracts written today survive). - Native session keys (post-mainnet, paired with programmable accounts).
- Live parachain operator network (designed for v1, implementation in a later phase; the interfaces ship at v1 so the design forward-commits).
- ZK-aggregated FALCON signatures (the path to substantially higher signature-verification throughput; v2/v3 work).
- zk-WASM proven execution (research-stage; integrated when the upstream provers reach production quality).
- Cross-chain bridges to other L1s (post-mainnet, only with proven security models).
This split is intentional. v1 ships the properties that justify Pyde's existence — post-quantum security, MEV resistance, sub-second finality, commodity-hardware decentralization, multi-language WASM contracts. Everything else is sequenced honestly and shipped when ready.
19.4 The Publishing Discipline
A discipline carried forward from the consensus pivot:
No external TPS claim is published until the performance harness exists, has been run on production-realistic conditions, and the methodology is reproducible by third parties. Publish only what the harness measures under sustained, production-realistic conditions — never burst, never microbenchmark, never single-machine if multi-region is the relevant scope.
The earlier consensus implementation hit roughly 4K TPS in lab tests despite a higher claimed design target. The discipline above prevents that gap from recurring. The v1 honest throughput target (to be established by the multi-region performance harness) on commodity validator hardware comes from this discipline.
See the Performance Harness companion document for the testing methodology.
19.5 What carries forward from the pivots
For context (see The Pivot for the full story):
- HotStuff-era consensus work — properties, lessons, and invariants carry forward; the code is archived and the consensus layer is being rebuilt around Mysticeti.
- Otigen-era execution work — the safety properties (reentrancy guards, checked arithmetic, typed storage, no
tx.origin, compile-time access-list inference) carry forward as patterns in the WASM host-function ABI and the binding generators; the language and custom VM are retired.
Both pivots reset the critical path for the affected layer but did not invalidate the work on adjacent layers (state, accounts, transactions, tokenomics, vesting, multisig, all preserved across both pivots).
19.6 Reading on
- Preface: The Pivot — context on both architectural pivots.
- Performance Harness — testing methodology.
- Chapter 16: Security — threat model and audit scope.
- Chapter 6: Consensus — the consensus design that ships at mainnet.
Chapter 20: Future Direction
This chapter is the post-v1 capability plan: what's deliberately deferred, why, what changes for users when each lands, and the reservations v1 makes so future work doesn't require breaking changes.
The discipline is simple: v1 ships interfaces, v2 ships implementations. Where a capability needs an on-chain hook to land cleanly later, v1 reserves the hook now — a tag byte, a struct field, a host-fn slot — so the future change is additive, not a hard fork of fundamentals. Where a capability is purely additive (new RPC, new SDK, new client tooling), no reservation is needed.
No calendar commitments. Items move on PIP merit, audit capacity, and ecosystem demand. The Post-Mainnet Plan in the appendix is the priority-sorted index; this chapter is the prose behind it.
20.1 Accounts and User Experience
The v1 account model is intentionally minimal: an address holds either an EOA (FALCON-512 keypair) or a contract. The reservations below let the model grow toward smart-account ergonomics without ever rotating the address shape, the auth-key encoding, or the multisig pipeline.
20.1.1 Programmable Accounts
What it is. An account whose authorization logic is itself a WASM contract — not a fixed-pubkey check. The auth contract decides whether a tx is authorized to spend from the account. Same shape as ERC-4337 / EIP-7702 in EVM-land, redesigned native.
Why deferred. The cost is high: every tx becomes a contract call for auth verification, the auth-contract has to be a deterministic subset of the full WASM ABI, and the simulation-vs-execution gap that trips up EVM account-abstraction needs to be carefully closed. None of that is hard; all of it takes audit cycles.
What v1 reserves. The AuthKeys enum carries a Programmable variant
(tag 0x03) that's defined in the wire format but rejected at
admission today. Account records carry a policy_mode flag with one
allowed value (Static); future Programmable accounts flip to the
contract-driven path. The address shape is unchanged.
What changes when it ships. Wallet UX gains paymasters, multi-call authorization, social recovery, conditional spending. Encrypted-lane support arrives in lockstep with the programmable account.
20.1.2 Session Keys
What it is. A short-lived keypair the account authorizes to act within a tight scope — bounded by which contracts it can call, which methods within those contracts, a spending cap, and an expiry. Revocable at any time. Designed for the dApp pattern where a user signs once and plays for an hour without re-authorizing every tx.
Why deferred. Session keys are only safe inside programmable accounts. A static FALCON pubkey with side keys would require either a new chain-level scope-enforcement engine (duplicate work) or trusting clients to enforce scope (worthless). Pair them with §20.1.1.
What v1 reserves. The AuthKeys::Programmable tag (above) is the
single chokepoint that gates this feature.
20.1.3 Native Multisig
What it is. A first-class multisig account where the signature threshold and signer set are stored on-chain and the chain itself checks the M-of-N condition — no contract wrapper, no deploy-a-multisig-per-team friction.
Why deferred. v1 currently routes multisig via the MultisigTx
transaction type, which is operational but not ergonomic — there's no
named account, no balance-of-multisig query, no auto-aggregation of
partial signatures. The v2 design uses the same on-chain pipeline,
just exposed through a real account type.
What v1 reserves. MultisigTx is already canonical; the v2 account
type is a wrapper over the same pipeline. No wire-format change.
20.1.4 Sponsored Transactions
What it is. A pattern where a paymaster account picks up the gas bill for a user's transaction — letting new users transact without holding PYDE first. dApps subsidize their own onboarding; protocol-level fee markets keep abuse bounded.
Why deferred. Sponsored txs require programmable accounts (the paymaster needs to enforce its own policy: "only sponsor calls to contract X up to budget Y") and a tx-envelope extension where the paymaster's signature is a peer of the sender's. Both depend on §20.1.1.
What v1 reserves. The fee-payer field on Tx is already an explicit
FeePayer enum (today only Sender); future variants Paymaster(addr)
and Sponsored(addr, sig) slot in without breaking the wire shape.
20.1.5 AI-Assisted Wallet Previews
What it is. The wallet shows the user not just "you're approving contract X to spend Y PYDE" but the downstream consequences: "this also grants method Z permissions to contract W for the next 30 days," or "this matches the drainer pattern seen in the last 200 phishing incidents."
Why deferred. v1 ships the foundation — every wallet can run a local WASM simulation of the tx and show the immediate state changes. The heuristic + LLM layers are additive client-side work; they don't need chain hooks.
What v1 reserves. Nothing — this is pure client-side innovation that gets better with ecosystem maturity. The wallet SDK already exposes the simulation primitives.
20.1.6 ENS-Style Name Extensions
What it is. Subdomains, name TTLs, off-chain text records, reverse lookups, multi-tier registry governance. Reaches feature parity with the most mature naming systems in the ecosystem.
Why deferred. v1 ships the core: 32-byte addresses, contracts and parachains use ENS-style unique names, the registry uniqueness check prevents collisions. The fancy bits (subdomains, TTLs) are additive on top of the existing registry.
What v1 reserves. The name registry uses a versioned record format; future extensions add fields without rotating the address shape or breaking existing resolution.
20.2 Cryptography
Three families of work: tightening existing primitives, adding zero-knowledge proofs, and the long-tail polish on threshold crypto.
20.2.1 Algebraic Batch FALCON Verification
What it is. A signature scheme that lets a verifier check N FALCON signatures in time substantially less than N individual verifications — typically O(log N) or O(N / k) — using algebraic identities over the underlying lattice ring.
Why deferred. The cryptographic construction is published; the engineering cost is reimplementing the FALCON verifier with batch math and re-auditing it. Worth doing once aggregation savings start to matter (high-throughput, high-N committees).
What it brings. Per-wave signature-verification cost drops sharply. Most useful for the wave-commit FALCON-cert path and the per-vertex producer signature.
20.2.2 ZK Validity Proofs
What it is. Zero-knowledge proofs (STARK or SNARK) attesting that a wave's state transition is correct, without re-executing the wave. Light clients verify a small proof instead of replaying transactions.
Why deferred. Pyde's primitive choice is already ZK-friendly (Poseidon2 for state root, Goldilocks field, JMT structure). The proving system itself is research-grade work — months of design, implementation, and audit. The economics also need rethinking: who runs the prover, how proving cost is priced, how the chain handles prover failure.
What v1 reserves. The state root is already Poseidon2 (provable cheaply). The state model is already a Merkle structure (JMT). No wire change needed when ZK lands; proofs become a new RPC and a new vertex field.
20.2.3 PSS Share-Extension Optimization
What it is. A protocol optimization for the case when the committee
grows or shrinks within a threshold tier (where t = 2f+1 stays
constant). Instead of running a fresh DKG, the existing committee
performs a one-round share extension to give the new member their
share of the existing polynomial — without changing the threshold
public key.
Why deferred. v1 ships fresh DKG on any committee size change, which is correct and fits comfortably in the 30-min DKG tail. The optimization is a single-round protocol versus a three-round DKG; it saves network bandwidth and avoids invalidating in-flight encrypted transactions across the resize.
What it brings. During bootstrap (when committee size grows validator-by-validator), encrypted transactions in the mempool stay valid across resize epochs instead of being invalidated by a new threshold public key. Once the committee stabilizes at the cap, the optimization is dormant.
When ships. After v1 mainnet is stable and PSS adversarial review is complete. The math is standard Lagrange interpolation — reuses the same path the decryption-share combine already uses.
20.2.4 Pedersen / KZG Commitments for PSS Resharing
What it is. Polynomial commitments attached to each resharing contribution, so a malicious old-committee member can't lie about the share they claim to be re-sharing without being caught.
Why deferred. v1's PSS-refresh relies on the participation-detector plus the resharing-contribution store to catch most equivocation. A polynomial-commitment scheme closes the remaining edge case where a malicious member emits a syntactically-valid contribution that doesn't actually correspond to the right share.
What v1 reserves. The resharing-contribution wire format has a
forward-compatible commitment slot (currently empty); future
contributions populate it without breaking older readers.
20.2.5 ML-KEM Stable Upgrade
What it is. The Kyber-768 implementation Pyde uses for threshold
mempool encryption is at 0.3.0-rc of the NIST FIPS 203 reference
crate. When the stable release ships, Pyde follows.
Why deferred. Operational, not protocol. The wire format is specification-locked at FIPS 203 finalization, so the upgrade is a dependency bump plus regression testing.
20.3 Execution and Performance
The v1 execution layer is uniform Block-STM. Beyond v1, the scaling story is layered — each layer is additive, gated on measurement, and non-breaking for contract authors. The Block-STM core ABI doesn't change.
20.3.1 Block-STM Scaling Layers
Each layer is independent; ship in the order that the measurements justify.
| Layer | What it does | Expected gain on its workload |
|---|---|---|
| L1: Access-list scheduling fast path | Use declared access lists to schedule conflict-free tx batches in parallel without MVCC overhead | 1.5–3× on access-list-heavy workloads |
| L2: Pipelined execution + consensus | Overlap wave N+1 execution with wave N consensus | ~2× |
| L3: Read-write set classification | Pre-classify txs as read-only / write-only / read-modify-write; run read-only path in parallel without conflict tracking | 2–5× |
| L4: GPU acceleration for PQ crypto | FALCON verify + Kyber decapsulate on GPU; encrypted-lane txs benefit most | 5–10× on encrypted-heavy workloads |
| L5: Native precompiles for hot patterns | Skip WASM execution entirely for common patterns (transfers, well-known token standards) | 10× on the specific patterns |
| L6: Execution sharding | Partition the wave across executor pools; merge state changes deterministically | Linear in shard count |
| L7: Chain sharding | Multi-chain state, cross-shard txs, post-mainnet whole-chain rewrite | Linear in shard count, with cross-shard overhead |
Not planned. Object-centric models (Sui-style) are
structurally incompatible with Pyde's slot-keyed sstore(slot, value)
model and would require breaking the host-function ABI. The decision
to stay slot-keyed is intentional and locked.
20.3.2 Two-Dimensional Gas
What it is. Gas accounts for two dimensions: execution cost (the v1 metric) and proving cost (when ZK validity proofs land). Some opcodes are cheap to execute but expensive to prove; a fair pricing model needs both dimensions.
Why deferred. Depends on ZK proving (§20.2.2) being far enough along to measure proving cost per opcode.
What v1 reserves. The gas accounting structure carries a single dimension today; the receipt format leaves room for the second without breaking older readers.
20.3.3 Persistent Receipt Store (Archive-Node Mode)
What it is. A separate node mode that retains every receipt forever, not just within the active state-sync window. Production block explorers and analytics services run on archive nodes.
Why deferred. Validator nodes don't need archived receipts; the explorer / indexer use case is operationally distinct. Engineering effort tracked for the storage layout, the snapshot inclusion path, and the operator runbook.
20.3.4 State Expiration
What it is. A protocol-level mechanism where state slots not accessed in N epochs become "expired" — their data is purged from active state but provable from archives. Reactivation requires submitting a proof.
Why deferred. The economics are research-grade. v1's state model is JMT with full retention; expiration overlays without breaking that.
What v1 reserves. The slot-key shape and JMT structure are both expiration-compatible without protocol changes.
20.4 Cross-Chain
20.4.1 Native Ethereum Bridge
What it is. A trust-minimized bridge between Pyde and Ethereum: a FALCON-in-EVM verifier contract on Ethereum (or a wrapper using the existing pairing-based aggregation), plus a Patricia-tree verifier on Pyde as a WASM contract that validates Ethereum state.
Why deferred. The verifier contracts are non-trivial — particularly the FALCON-in-EVM side, which needs careful gas-cost analysis. Bridges are the most-attacked surface in DeFi; getting this right is audit-heavy.
What it brings. Native token bridging without trusted custodians. Pyde dApps gain reach into the largest existing user base.
20.4.2 Native Bitcoin Bridge
What it is. SPV-style bridge: Pyde validates Bitcoin block headers and verifies inclusion proofs for specific UTXOs.
Why deferred. Bitcoin's PoW finality is probabilistic, not BFT; operating across different finality models needs careful design of the confirmation policy. Lower priority than Ethereum since Bitcoin DeFi is smaller surface area.
20.4.3 Parachain SDKs
What it is. First-class SDKs in Rust, Go, and C++ for building parachains. v1 ships the parachain runtime; SDKs reduce per-author boilerplate.
Why deferred. v1's design is no-SDK by intent — parachain authors declare their host imports manually and compile any wasm32-target language. This proves the model works without lock-in to a specific SDK. SDKs are a downstream productivity layer.
20.4.4 TypeScript SDK Feature Parity
What it is. The TypeScript SDK reaches full feature parity with the Rust SDK across encrypted-lane txs, threshold queries, parachain operations, and event subscriptions.
Why deferred. v1 ships the WASM bridge (pyde-crypto-wasm) which the TypeScript SDK already vendors. Filling out the remaining surface is incremental work on top.
20.5 Operations and Network Hardening
20.5.1 Sentry-Node Validator Hiding
What it is. Pattern where validator nodes are not directly reachable on the public network — they peer only with operator-controlled sentry nodes that absorb DoS attempts and front the gossip topology.
Why deferred. Operational pattern, not a protocol feature. Validators configure their own sentry topology; the chain doesn't need to know.
What v1 reserves. The validator's peer-discovery layer already supports the sentry pattern through the standard libp2p peer relationships. No protocol change needed.
20.5.2 Sophisticated Peer Scoring
What it is. Multi-topic peer scoring with decay parameters, weighted by topic priority, used to throttle or eject misbehaving peers before they hit slashable thresholds.
Why deferred. v1 ships basic per-peer rate limiting; the multi-topic decay model is operational polish.
20.5.3 Signed-Mempool Commitments + Censorship Slashing
What it is. Each validator periodically signs a commitment over the mempool view they've seen, broadcast publicly. If a validator's proposed wave omits a transaction that's been in their signed mempool view for K rounds, that's evidence of censorship and is slashable.
Why deferred. The mechanism design is subtle — you need to handle legitimate omissions (insufficient gas, denylisted addresses) without either creating false positives or letting real censorship slip through. Requires its own PIP and audit.
What v1 reserves. The mempool view already exposes a stable identifier per transaction; future censorship-slashing reads from that identifier without needing wire changes.
20.5.4 Mempool-Level Emergency Pause
What it is. During an emergency pause, the mempool refuses admission at the gateway instead of accepting transactions and rejecting them at wave-commit time. Cleaner UX, less waste.
Why deferred. The current emergency-pause gate-check at admission works correctly; the mempool-level optimization is operational polish.
20.5.5 Graceful Drain-and-Shutdown on Persist Failure
What it is. When a validator hits a persistent disk or RocksDB failure, it drains its current wave commitments cleanly instead of crashing mid-commit.
Why deferred. Operational hardening. The current crash-on-failure path is correct (no partial state ever persisted) but loud.
20.5.6 Off-Chain Merkle Builder CLI
What it is. A small CLI for operators to build Merkle roots from arbitrary data — useful for airdrops, allowlist rollouts, and other operational batched-proof patterns.
Why deferred. ~150 LOC of tooling. Builds when an operator asks.
20.6 Native Browser Wallet
What it is. A first-party browser wallet for Pyde — keystore, signing, contract interaction, network management. Same shape as MetaMask but native to Pyde's primitive set (FALCON keys, Kyber envelopes for encrypted-lane txs).
Why deferred. v1 ships the primitives the wallet needs (pyde-crypto-wasm, the JSON-RPC surface, deterministic key derivation); the wallet itself is an ecosystem deliverable. The toolchain ships otigen for developers; the wallet ships for users.
What v1 reserves. The wallet doesn't need protocol reservations. It needs the simulation primitives (already exposed) and the encrypted-tx envelope construction path (already exposed). The deferred work is the wallet UX itself, not the chain hooks.
20.7 What's Explicitly Not Planned
A short list of things considered and deliberately rejected:
-
Object-centric execution model. Sui's object ownership pattern is structurally incompatible with Pyde's slot-keyed sstore. Picking it up later would require breaking the host-function ABI. Decision locked.
-
Session keys without programmable accounts. Possible to layer on top of static FALCON keys, but unsafe — scope enforcement would have to live in clients, which is worthless. Tied to programmable accounts forever.
-
Otigen→native compile. Considered; rejected. Determinism, sandboxing, and metering are weaker for direct-native code than for WASM. Precompiles (§20.3.1 L5) cover the performance cases without losing those properties.
-
Gas refunds. v1 ships zero gas refunds. EIP-3529 in EVM-land showed refunds are net-negative for user incentives once you understand the second-order effects. Pyde has PIP-4 (state-cleanup pricing) which makes refund-as-incentive unnecessary.
-
On-chain governance switch. Pyde uses voluntary validator upgrades, not on-chain rule-changes. No protocol-level governance vote can flip consensus rules without each validator opting in by running new software. See Chapter 18.
20.8 The Discipline
The list above is long because v1 is intentionally lean. Each deferred item is justifiable on its own merits — but the principle that ties them together is the one stated at the top:
v1 ships interfaces, v2 ships implementations.
Each capability above either lands additively (new RPC, new SDK, new
contract type) or slots into a reservation v1 has already made
(AuthKeys::Programmable tag, FeePayer::Paymaster variant,
commitment slot on resharing contributions). The pieces that need
protocol-level breaking changes — Sui-style objects, gas refunds,
on-chain governance — are not planned and aren't coming.
This is the bet: a small, correct, audited v1 surface is worth more than a feature-rich one. Everything in this chapter is an addition to that surface, not a replacement for it.
Validator Operator Guide
This guide is the practical, command-driven side of running a Pyde validator. The companion specs (VALIDATOR_LIFECYCLE, SLASHING, STATE_SYNC, CHAIN_HALT) cover the formal design; this guide walks you through actually doing it.
The current entry point is the Soft Testnet Quickstart — from a clean machine to a validator that's signing, committing, and earning rewards on a multi-validator test network. Pyde is pre-mainnet; mainnet operator docs land closer to launch.
What a Pyde validator does
Three loops, all running in one pyde validator process:
- Vertex producer — every producer tick, when enough peer parents are in the local DAG, build + sign a Mysticeti vertex, insert it into the local DAG, gossip it on
pyde/vertices/1. - Wave committer — once the 3-stage rule fires (anchor + supporters at R+1 + certifiers at R+2), assemble a
WaveCommitRecord, emit a beacon share for the next epoch, emit DKG attestations for the next-epoch ceremony when crossing into a new target epoch, and snapshot the DKG attestation buffer at the last wave of every epoch. - Slashing detectors — equivocation, downtime, bad anchor attestations, bad state-root signatures, invalid vertex structure, DKG participation failure — each watches its own evidence channel and submits an on-chain
Slashtx when it sees something.
The CLI surface for operating it lives under pyde stake (register, unbond, claim, unjail, rotate, status), pyde keys (FALCON keypair management), and pyde genesis (genesis manifest utilities).
Lifecycle at a glance
pyde keys generate pyde stake register
unregistered ────────────────────────▶ EOA ──────────────────▶ Active
│
│ pyde stake unbond
▼
Unbonding ───────▶ Exited
▲ ▲
│ │ pyde stake claim
slash-tx │ │ after UNBONDING_PERIOD_WAVES
Jailed
│
│ pyde stake unjail after jail_until_wave
▼
Active
Every transition is one pyde stake <subcommand> away. The CLI builds the tx, signs it with your FALCON keypair, submits over JSON-RPC, and polls the receipt — no curl scripts needed.
Next
- Soft Testnet Quickstart — the end-to-end path from a clean machine to a committing validator.
Soft Testnet Quickstart
From a clean machine to a validator that's signing, committing waves, and earning rewards on a multi-validator Pyde test network.
This is the operator path. Contract authors want the Otigen Toolchain Guide instead.
TL;DR — see it work locally first
If you just want to confirm Pyde's multi-validator path works on your machine before committing to a real testnet setup, build the binary and run:
pyde validator-cluster --n 4
That spins up a 4-validator devnet in one process — generates the FALCON + libp2p keypairs, writes a genesis manifest, full-mesh-dials every pair, applies the BFT producer quorum, and runs real FALCON beacons + the real DKG ceremony. The cluster commits waves within ~45 seconds; Ctrl+C cleanly shuts everything down and prints each validator's final waves_committed count.
═══════════════════════════════════════════════════════════════
Pyde validator cluster — 4 validators, BFT quorum 3
═══════════════════════════════════════════════════════════════
Chain id: 31337
Producer tick: 50 ms
Committer tick: 50 ms
Epoch length: 5 waves
Cluster data dir: /tmp/pyde-validator-cluster-12345-1781178157
Ctrl-C to shut down all validators.
═══════════════════════════════════════════════════════════════
…
^C
pyde validator-cluster: shutdown signal received
v0: clean exit, waves_committed=12
v1: clean exit, waves_committed=12
v2: clean exit, waves_committed=12
v3: clean exit, waves_committed=11
That's the full multi-validator pipeline running locally. No genesis manifest to write, no peers to dial, no funding to arrange. Useful when you want to know everything boots before walking the production-shape steps below.
Flags worth knowing:
| Flag | Default | What it does |
|---|---|---|
--n <N> | 4 | Number of validators. Uses bft_quorum_for(N) for both the producer and support quorum. |
--producer-tick-ms <MS> | 50 | Per-validator tick rate. Lower = faster wave commits. |
--committer-tick-ms <MS> | 50 | Wave-committer tick rate. |
--epoch-length-waves <N> | 5 | Waves per epoch. Production is 100; clusters use 5 so epoch boundaries arrive in seconds. |
--chain-id <N> | 31337 | Chain id baked into the generated genesis. |
The rest of this guide walks the production-shape setup — separate processes, real keypair management, on-chain registration — that you'd use for a real soft testnet.
0. Prerequisites
You need ~4 GB free disk and inbound network reachability (a public IP or a forwarded port — Pyde is libp2p-based, peers need to dial you back). Pyde ships as a prebuilt binary; no Rust toolchain required.
Install the pyde binary from the public release mirror:
curl -fsSL https://raw.githubusercontent.com/pyde-net/test-releases/main/engine/install.sh | bash
The installer probes the GitHub API anonymously — no token needed. It downloads the latest engine-vX.Y.Z release for your platform, verifies the SHA-256 checksum, places pyde at ~/.pyde/bin/pyde, and adds that directory to your shell rc. Open a new shell (or source your rc) and:
pyde --version
pyde 0.1.0
Supported platforms: macOS arm64, Linux x86_64, Linux aarch64. Windows operators run the install script from Git Bash or WSL. To pin a specific release:
curl -fsSL https://raw.githubusercontent.com/pyde-net/test-releases/main/engine/install.sh | bash -s -- --version v0.1.0-testnet.1
Optional: independently verify the release with the sigstore-keyless signature attached to every artifact. See the Joining a Public Testnet chapter for the full verification flow.
1. Generate your validator keypair
Pyde uses post-quantum FALCON-512 signatures. Generate yours off-validator on a machine you trust:
pyde keys generate \
--out ./falcon.keypair \
--password-stdin <<< 'change-me-to-a-real-passphrase'
This writes an Argon2id + ChaCha20-Poly1305 encrypted FALCON keypair. Treat it the way you'd treat a hardware-wallet seed phrase — it's the single secret that controls your validator's stake.
To inspect the public material (no password required):
pyde keys inspect ./falcon.keypair
falcon_pubkey: 0x1f3a9b… (897 bytes)
validator_addr: 0xa4c2… (Poseidon2 of pubkey, 32 bytes)
The validator_addr is what the chain identifies you by. Save it.
You'll also need a libp2p Ed25519 keypair for the peer-to-peer layer. pyde validator generates one on first boot if you point --keypair at a non-existent path, so most operators skip an explicit step here.
2. Pick a network onboarding path
Two ways to join a soft testnet:
- Path A — bootstrap a fresh network. You and N other operators agree on a genesis manifest, every operator points
pyde validator --genesisat the same file. Use this when you're starting a new test network. - Path B — join an existing network. You state-sync from a peer that's already running. Use this for everything after the initial bootstrap.
If you're not sure, you want Path B. Skip to it.
Path A — Bootstrap a fresh network
Write a template genesis manifest:
pyde genesis template \
--output ./genesis.toml \
--chain-id 31337 \
--chain-name "soft-testnet"
Edit it. The interesting fields:
committee— one entry per founding validator. Each entry carries that validator'sfalcon_pubkey,operator_address, andstake_quanta(must be>= MIN_VALIDATOR_STAKE = 10_000_000_000_000 quanta = 10,000 PYDE).prefund— initial balances. At minimum prefund every committee member'soperator_addressso they can pay gas.economic.epoch_length_waves— keep at100for production-shape, drop to5or10if you want fast epoch boundaries during testing.
Validate the file:
pyde genesis validate --genesis ./genesis.toml
Distribute the file to every founding operator. Everyone must boot pyde validator with the same --genesis path; the chain ID inside the manifest is what binds them to the same network.
Then jump to step 4 — Run the validator.
Path B — Join an existing network via state-sync
Get a trusted peer's RPC endpoint (their pyde validator --rpc-listen address) and an out-of-band copy of their current (wave_id, state_root) pair. The state root is published by the chain on the wave commit record; in practice you'll grab it from the running peer:
curl -s http://peer.example.com:9933 \
-X POST -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","method":"pyde_getSnapshotManifest","id":1}' | jq .result
{
"wave_id": 12345,
"state_root": "0xabcd0123…",
"chunk_size": 4096,
"chunk_count": 17,
"chunk_hashes": ["0x…", "0x…", "..."],
"total_keys": 65536
}
The lightweight manifest RPC is cheap on both sides — no chunks are transmitted. Reconcile the wave_id and state_root against a reference you trust independently (a public mirror, an audited validator, a committee-signed checkpoint from your own infrastructure). The whole point of the weak-subjectivity flow is that the peer serving the snapshot is untrusted; your reconciliation is what makes it safe.
Once you've verified the manifest matches your reference, you'll pass the pair to pyde validator as --state-sync-checkpoint <wave_id>:<state_root_hex> in step 4. Save the values:
export PYDE_PEER_RPC=http://peer.example.com:9933
export PYDE_CHECKPOINT=12345:0xabcd0123…
3. Pre-fund your operator address (Path B only)
To register as a validator on an existing network you need ≥ MIN_VALIDATOR_STAKE + gas worth of PYDE at your operator_address. Path A operators already prefunded themselves in the genesis manifest; Path B operators need to receive a transfer from someone who holds testnet PYDE.
Ask the operator running the network's faucet (or any holder) to send to your operator_address:
# (on the funder's machine)
# Build + sign + submit a Standard transfer. Use whatever tooling
# you have; the value must be ≥ 10_000_010_000_000 quanta
# (10,000 PYDE stake + headroom for gas).
Verify the balance landed:
curl -s "$PYDE_PEER_RPC" \
-X POST -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","method":"pyde_getBalance","params":["0xYOUR_ADDR"],"id":1}'
{"jsonrpc":"2.0","id":1,"result":"0x9184e72a000"}
4. Run the validator
For Path A:
pyde validator \
--keypair ./libp2p.keypair \
--falcon-keypair ./falcon.keypair \
--falcon-password-stdin \
--consensus-store-path ./consensus_store \
--listen /ip4/0.0.0.0/tcp/0 \
--bootnodes ./bootnodes.txt \
--rpc-listen 0.0.0.0:9933 \
--genesis ./genesis.toml \
--validator-id 0 \
--producer-tick-ms 50 \
--committer-tick-ms 50 \
--falcon-beacon <<< 'your-keypair-password'
For Path B:
pyde validator \
--keypair ./libp2p.keypair \
--falcon-keypair ./falcon.keypair \
--falcon-password-stdin \
--consensus-store-path ./consensus_store \
--listen /ip4/0.0.0.0/tcp/0 \
--bootnodes ./bootnodes.txt \
--rpc-listen 0.0.0.0:9933 \
--state-sync "$PYDE_PEER_RPC" \
--state-sync-checkpoint "$PYDE_CHECKPOINT" \
--validator-id 0 \
--producer-tick-ms 50 \
--committer-tick-ms 50 \
--falcon-beacon <<< 'your-keypair-password'
Flags worth knowing:
| Flag | Why |
|---|---|
--bootnodes <FILE> | File of multiaddrs to dial at startup. Path B operators receive this from the network's bootstrap docs. |
--listen <MULTIADDR> | What address pyde binds for incoming peer connections. tcp/0 lets the OS pick a port; pin a specific one if you're behind a NAT and need to forward it. |
--rpc-listen <ADDR> | JSON-RPC bind. Required if you want to use pyde stake against your own node. Skip if you'd rather talk to a separate RPC node. |
--state-sync <FILE_OR_URL> | Path B only. Local borsh file OR an HTTP(S) URL pointing at a peer's RPC. The validator fetches the snapshot, applies it, then tail-replays missing waves before joining consensus. |
--state-sync-checkpoint <WAVE_ID>:<HEX> | Pin the snapshot's expected (wave_id, state_root). Refuses to boot on mismatch. Always supply this with --state-sync — without it you're trusting the peer URL. |
--validator-id <N> | Your committee slot. For Path A you and the other founders pick distinct ids; for Path B the chain assigns it when you register (set to 0 here, then re-launch with the assigned id after registration). |
--falcon-beacon | Use the production FALCON-512 beacon scheme (vs. the mock for dev). Always on for testnet+. |
You should see something like:
validator: snapshot manifest matches operator checkpoint
validator: snapshot applied; entering tail-replay
validator: tail-replay persistence complete waves_persisted=3247 txs_persisted=8112
validator: tail-replay walk_chain_log re-executed tail waves
validator: vertex producer started tick_ms=50 quorum=5
validator: wave committer started
…
wave committer: snapshotted DKG attestations target_epoch=124 buffered=7 written=21
Leave it running. Move to a new terminal for step 5.
5. Register your validator on-chain
You're now running a pyde validator process and (Path B) talking to a state-synced chain. The chain has your account but no ValidatorRecord yet. Register it:
pyde stake register \
--rpc http://localhost:9933 \
--falcon-keypair ./falcon.keypair \
--falcon-password-stdin \
--amount 10000000000000 \
--chain-id 31337 <<< 'your-keypair-password'
StakeDeposit submitted
tx_hash: 0x8a3f…
validator_address: 0xa4c2…
Poll for confirmation:
pyde stake status \
--rpc http://localhost:9933 \
--falcon-keypair ./falcon.keypair \
--falcon-password-stdin <<< 'your-keypair-password'
ValidatorRecord
status: Active
stake: 10_000_000_000_000 quanta (10,000 PYDE)
pubkey: 0x1f3a9b…
unbond_at_wave: null
jail_until_wave: null
last_claimed_rps: 0
Path A note: founding-committee operators are already registered at genesis — skip this step.
6. Verify the validator is healthy
Two quick checks. First, your wave-commit metric should be advancing:
curl -s http://localhost:9933 \
-X POST -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","method":"pyde_getMetrics","id":1}' | jq .result.waves_committed
"42"
Second, the DKG participation detector should NOT be slashing you:
curl -s http://localhost:9933 \
-X POST -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","method":"pyde_getMetrics","id":1}' \
| jq '.result.dkg_participation_failures_detected, .result.dkg_attestations_received'
"0"
"127"
Zero failures detected, attestations flowing — you're a good citizen.
7. Day-2 ops
Every lifecycle transition is one pyde stake subcommand. The full surface:
| Subcommand | Effect |
|---|---|
pyde stake status | Read-only — query your on-chain ValidatorRecord. No signing. |
pyde stake register | Submit StakeDeposit. Must hold ≥ MIN_VALIDATOR_STAKE + gas at the operator address. |
pyde stake rotate --new-pubkey … | Swap your FALCON keypair. Authorised by the OLD key; after success the new key controls the address. Run while Active. |
pyde stake unbond | Begin the unbonding period. Validator transitions to Unbonding; stake stays locked through UNBONDING_PERIOD_WAVES = 5,184,000 waves. |
pyde stake claim | Claim accrued rewards. After UNBONDING_PERIOD_WAVES waves past unbond, this also transitions the record to Exited and refunds the stake. |
pyde stake unjail | Release a Jailed validator back to Active. Allowed only after jail_until_wave has elapsed; costs an UNJAIL_FEE. |
Each subcommand takes --rpc, --falcon-keypair, optional --falcon-password-stdin, --gas-limit, and --chain-id. Run pyde stake <subcommand> --help for the exact flag set.
Where to go next
- The Validator Lifecycle companion spec covers state transitions, slashing rules, and unbonding/jail constants in formal detail.
- The State Sync companion spec explains the snapshot format, weak-subjectivity checkpoints, and the tail-replay design.
- The Slashing companion spec enumerates every offense, its evidence shape, its slash amount, and its jail period.
- The Chain Halt & Recovery companion spec covers what to do when the network stalls.
Public testnet bootstrap docs (bootnodes, genesis hash, initial committee) will live in pyde-net/testnet once the soft testnet launches.
Public Testnet Bootstrap Runbook
How the testnet's initial committee actually launches the network — from "we agreed to run a testnet" to "external operators can curl-install + join". This is the bootstrapper's side of the Joining a Public Testnet flow.
Audience: the operator (or small operator group) coordinating a fresh Pyde public testnet launch. If you're a downstream operator joining an already-running testnet, Joining a Public Testnet is the chapter you want.
Scope: one full bootstrap cycle. Pick a chain id, agree on a committee, mint genesis + checkpoint + bootnodes, publish to the release mirror, run the initial committee, accept external validators. The whole arc.
1. Prerequisites the bootstrap operator owns
Decisions that need to be made BEFORE any binary runs. These are bound into the genesis manifest and can't change without a full re-bootstrap.
| Decision | Recommendation | Notes |
|---|---|---|
| Chain id | Use the chain-id registry to pick an unused id. v1 Pyde testnet uses 11_155_111 (Sepolia-style sentinel) | Once tagged, every tx signed against the chain id is replay-locked to it. Reusing an existing chain id risks tx replay from another network. |
| Chain name | A short kebab-case slug like pyde-testnet-1 | Surfaced in pyde_getNodeInfo, in operator banners, and in the release notes |
| Committee size | 4–7 for the first testnet | Too small = small-cluster mesh fragility (use #313 small_cluster_mesh: true); too large = coordination overhead during bootstrap. Production target is 128. |
| Epoch length | 100 waves at first | Drop to 10 or 5 during smoke tests; production-shape is 100. |
| Dispute window | 6 epochs | Default. Tightens to 3-4 for high-tempo testnets if you want faster slashing finality. |
| Genesis timestamp | Now (date +%s) | The chain doesn't care about wall-clock past genesis; operators do for "when did this testnet start." |
| Initial prefund accounts | Every committee operator address + ~20 dev accounts | The dev accounts let the faucet, the bootstrap-side soak-test runs, and downstream contract authors have funded EOAs without each begging for transfers. |
Once these are locked, every committee member needs to know them — they all bind into the genesis manifest's chain-identity hash and must match byte-for-byte.
2. Generate the committee keypairs
Each committee operator generates their own FALCON-512 keypair offline. Never centralise this step — the bootstrap operator does NOT generate keys on behalf of others; only the operator who controls a key knows the password.
On each committee member's machine:
# Generate FALCON keypair (validator identity)
pyde keys generate \
--out ./falcon-${OPERATOR_NAME}.keypair \
--password-stdin <<< 'your-strong-passphrase'
# Export just the public half — this is what the bootstrap operator needs
pyde keys export-pubkey ./falcon-${OPERATOR_NAME}.keypair \
--format hex > ./falcon-${OPERATOR_NAME}.pub
# Read the corresponding validator address
pyde keys inspect ./falcon-${OPERATOR_NAME}.keypair
The operator sends only the .pub file (the 897-byte hex pubkey) + the derived validator address to the bootstrap coordinator. Never the keypair file itself + never the passphrase.
Each operator also generates a libp2p Ed25519 keypair — pyde validator does this automatically on first boot if --keypair points at a non-existent path. The bootstrap operator needs the libp2p PeerId from each committee member too, since those go into the published bootnodes list. The PeerId is derived from the libp2p keypair on first boot; the operator extracts + sends it:
# After first validator boot (which generates the keypair):
pyde keys inspect-libp2p ./libp2p.kp # prints PeerId in 12D3KooW... form
3. Mint the genesis manifest
Once the bootstrap coordinator has every operator's .pub + validator address + PeerId, they assemble genesis.toml:
pyde genesis template \
--output ./genesis.toml \
--chain-id 11155111 \
--chain-name "pyde-testnet-1"
Then edit genesis.toml to fill in:
committee— one entry per founding operator. Each entry:[[committee]] member_id = 0 falcon_pubkey = "0x<paste the .pub hex here>" operator_address = "0x<paste the validator address here>" stake_quanta = 10000000000000 # MIN_VALIDATOR_STAKE = 10,000 PYDEprefund— at minimum everyoperator_addressfrom the committee table above (each needs gas headroom). Plus dev accounts.economic.epoch_length_waves— pick per § 1.economic.dispute_window_epochs— pick per § 1.genesis_timestamp_unix—date +%sat the moment of mint.
Validate the manifest:
pyde genesis validate --genesis ./genesis.toml
chain_name: pyde-testnet-1
chain_id: 11155111
chain_identity: 0x... ← THIS is the binding fingerprint
committee: 7 founding validators
prefund: 27 accounts
Distribute the genesis.toml + chain_identity hash to every committee member out-of-band. Email, Signal, encrypted Slack — whatever the bootstrap group already uses. Every member must boot against the same file. The chain_identity hash is what they verify against once they've downloaded.
4. Assemble the bootnodes list
A bootnode is just a pyde validator with a stable, publicly reachable libp2p address. New operators dial bootnodes on first boot; once the gossipsub mesh forms, gossip finds the rest of the network.
Convention: every committee member runs as a bootnode for the first ~30 days of testnet life. After that the bootstrap operator can prune to 3–5 stable ones.
bootnodes.txt:
# Pyde Testnet bootnodes — operator-run, stable across the testnet lifetime.
# One multiaddr per line. Lines starting with `#` are comments.
# Operator: alice (committee member_id=0)
/dns4/bootnode-alice.testnet.pyde.network/tcp/30303/p2p/12D3KooW<peer-id>
# Operator: bob (committee member_id=1)
/dns4/bootnode-bob.testnet.pyde.network/tcp/30303/p2p/12D3KooW<peer-id>
# Operator: carol (committee member_id=2)
/dns4/bootnode-carol.testnet.pyde.network/tcp/30303/p2p/12D3KooW<peer-id>
Two requirements per bootnode address:
- Stable DNS or IP. Don't use AWS spot instances, residential IPs, or anything that re-rolls on reboot. Use a stable hostname; rotate the underlying VM behind it without breaking the multiaddr.
- Reachable inbound TCP 30303 (or whichever port matches your bootnodes.txt). The bootnode operator's firewall must allow inbound on the listed port; new operators dialing in fail silently otherwise.
5. Mint the initial weak-subjectivity checkpoint
The first checkpoint is a wave_id:state_root pair that pins the snapshot a new validator's state-sync source must produce before it'll trust the data. The bootstrap operator mints this after the chain has run for a few epochs (so there's actual committed state to anchor against), then publishes it to the release mirror.
# On any healthy committee validator:
curl -s http://localhost:9933 \
-X POST -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","method":"pyde_getSnapshotManifest","id":1}' \
| jq -r '.result | "\(.wave_id):\(.state_root.blake3)"' \
> ./checkpoint.txt
cat ./checkpoint.txt
# 4200:0x9a3f...
Refresh weekly (or whenever the bootstrap operator wants to narrow the trust window further). Each new checkpoint commits to a more-recent state; older checkpoints stay valid but a new operator following them ends up with a longer tail-replay.
6. Publish to the release mirror
Once all four artifacts are ready (genesis.toml, bootnodes.txt, checkpoint.txt, and the validator binary release), the bootstrap operator pushes a release tag on pyde-net/engine:
git tag v0.1.0-testnet.1
git push origin v0.1.0-testnet.1
The release pipeline:
- Runs the gates (fmt + clippy + workspace tests + doc).
- Builds the binary per platform.
- Sigstore-signs every artifact.
- Publishes to
pyde-net/test-releasesunder tagengine-v0.1.0-testnet.1.
Then the bootstrap operator manually attaches the bootstrap artifacts to that release via the GitHub UI (or gh release upload):
gh release upload engine-v0.1.0-testnet.1 \
--repo pyde-net/test-releases \
./genesis.toml \
./genesis.toml.sha256 \
./genesis.toml.sig \
./genesis.toml.pem \
./bootnodes.txt \
./checkpoint.txt
The sigstore artifacts come from a manual cosign sign-blob against genesis.toml (and optionally bootnodes.txt + checkpoint.txt) using the bootstrap operator's GitHub identity:
COSIGN_EXPERIMENTAL=1 cosign sign-blob --yes \
--output-signature genesis.toml.sig \
--output-certificate genesis.toml.pem \
genesis.toml
After publish, anyone can verify per Joining a Public Testnet § 3.
7. Run the initial committee
Every committee member boots their validator with the now-published genesis + bootnodes:
# Download the published artifacts
RELEASE_TAG=engine-v0.1.0-testnet.1
BASE="https://github.com/pyde-net/test-releases/releases/download/${RELEASE_TAG}"
mkdir -p /etc/pyde
sudo cp ./falcon-${OPERATOR_NAME}.keypair /etc/pyde/falcon.keypair
sudo curl -fsSLo /etc/pyde/genesis.toml "${BASE}/genesis.toml"
sudo curl -fsSLo /etc/pyde/bootnodes.txt "${BASE}/bootnodes.txt"
# Boot — first committee members boot against bare genesis (no state-sync;
# the chain is fresh, there's nothing to sync against). Later joiners use
# --state-sync per Joining a Public Testnet § 5.
sudo systemctl start pyde-validator
sudo journalctl -u pyde-validator -f
You should see, in order:
validator: chain seeded from genesis— the validator's seed pass succeeds against the published manifest.validator: bound + dialing— listening on its public address, dialing the bootnodes list.wave committer: ...lines — committing waves once enough committee members are online (BFT-quorum from § 1).
When the chain commits its first wave, the testnet is alive.
8. Open the network to external operators
At this point downstream operators can follow Joining a Public Testnet. The bootstrap operator's remaining responsibilities:
- Run a public state-sync RPC endpoint — at least one committee validator exposes
--rpc-listen 0.0.0.0:9933behind TLS termination + rate limiting so downstream operators can--state-sync https://state-sync.testnet.pyde.network. Don't expose the raw RPC port — there's no auth onpyde_sendRawTransaction. - Publish checkpoint refreshes weekly — re-mint
checkpoint.txtfrom a current validator + upload to the latest release. - Watch the alerts — set up Prometheus + the shipped alert rules so the bootstrap operator notices when the chain halts before downstream operators do.
- Coordinate hard upgrades — when the engine releases a chain-breaking change, the bootstrap operator decides whether to re-tag, re-mint genesis, and coordinate a re-bootstrap or whether the change is backwards-compatible.
9. What "testnet works perfectly" means in practice
The honest bar for a public unaudited testnet:
| Property | Bar | Verified by |
|---|---|---|
| Chain commits waves continuously | ≥ 99% wave-commit rate over 7+ days | Operations dashboard |
| External operators can join | Someone non-bootstrap follows the docs cold and lands a healthy validator | The external-validator drill (planned task) |
| Slashing detectors don't false-positive | Zero unjustified slashes over the soak-test window | DKG participation + equivocation counters |
| Receipts resolve | 100% of submitted txs return a receipt within 30s | The pyde soak workload generator |
| State sync works for fresh joiners | New validator can join from snapshot in < 1 hour | Manual drill |
| Network survives validator churn | Restart any one validator; chain doesn't halt | kill -9 drill |
Test each one explicitly before announcing the testnet to the public. The dashboards + alerts are the day-to-day watcher; the explicit drills are the launch gate.
10. Re-bootstrap (when chain-breaking changes ship)
If the engine ships a chain-breaking change (consensus rule change, on-disk format change, new mandatory tx field, etc.), the existing testnet's state cannot be carried forward. The bootstrap operator:
- Announces the re-bootstrap window — usually 1–2 weeks of notice to downstream operators.
- Bumps the chain id in the new genesis (don't reuse the old one — that risks tx replay).
- Re-runs §§ 3 → 7 with the new release tag (
v0.1.0-testnet.2, etc.). - Marks the old release as superseded on the mirror; doesn't delete it (downstream operators may need to compare).
- Updates the testnet docs with the new chain id + release tag.
The first 3–6 months of any new chain typically see 1–3 re-bootstraps. Plan for it.
Where to go next
- Joining a Public Testnet — what downstream operators do once you've published.
- Day-2 Operations — the production-side of running a validator (systemd, monitoring, log rotation, key rotation).
- The Chain Halt & Recovery companion spec — playbook for when the chain stops committing.
Joining a Public Testnet
How to point your validator at a specific Pyde public testnet — fetch the genesis manifest, verify it's the one the network was bootstrapped against, configure your bootnodes, register your stake on-chain.
This chapter assumes you've already followed the Quickstart through step 1 (you have the pyde binary installed and a FALCON keypair generated). It picks up where the quickstart's Path B — Join an existing network branch starts.
The trust chain
A public testnet has one canonical genesis manifest. Every validator on the network booted against the same genesis.toml (or a byte-identical copy); the manifest's content hash binds them all to the same chain identity. Joining the network means downloading that exact file and verifying it byte-for-byte matches what every other validator is running.
Two artifacts make this honest:
- The genesis manifest itself (
genesis.toml) — the human-readable network spec. - A SHA-256 checksum + sigstore-keyless signature — published alongside the manifest so you can prove the file you downloaded is the one the bootstrappers actually published.
Both are hosted on the public release mirror (pyde-net/test-releases) under the same release tag as the validator binary you installed. Same trust root, same URL pattern.
1. Download the genesis manifest + verification artifacts
Pick the release tag that matches your installed binary (e.g. v0.1.0-testnet.1). Every release ships three files relevant to genesis:
genesis.toml— the manifestgenesis.toml.sha256— the SHA-256 checksumgenesis.toml.sig+genesis.toml.pem— the sigstore-keyless signature + ephemeral cert
Fetch them:
RELEASE_TAG=engine-v0.1.0-testnet.1
mkdir -p ~/pyde-testnet && cd ~/pyde-testnet
BASE="https://github.com/pyde-net/test-releases/releases/download/${RELEASE_TAG}"
curl -fsSL -O "${BASE}/genesis.toml"
curl -fsSL -O "${BASE}/genesis.toml.sha256"
curl -fsSL -O "${BASE}/genesis.toml.sig"
curl -fsSL -O "${BASE}/genesis.toml.pem"
All four files now live in ~/pyde-testnet/.
2. Verify the SHA-256 checksum
Confirms the manifest hasn't been corrupted in transit or replaced by something else with the same filename.
shasum -a 256 -c genesis.toml.sha256
genesis.toml: OK
If it says FAILED, stop immediately — re-download from the canonical mirror; if it still fails, post on the operator channel before proceeding.
The checksum alone proves the file matches the published one; it doesn't prove who published it. That's what the sigstore signature is for.
3. Verify the sigstore signature (optional but recommended)
Sigstore-keyless signing binds the genesis file to the GitHub Actions workflow that published it. The signature includes an ephemeral cert proving the signer was the pyde-net/engine release workflow at the tagged commit. Anyone can verify without us managing long-lived signing keys.
Install cosign if you don't have it:
# macOS
brew install cosign
# Linux (binary download)
curl -fsSL -o cosign \
https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
chmod +x cosign && sudo mv cosign /usr/local/bin/
Verify:
cosign verify-blob \
--certificate genesis.toml.pem \
--signature genesis.toml.sig \
--certificate-identity-regexp 'https://github.com/pyde-net/engine/\.github/workflows/release\.yml@.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
genesis.toml
Verified OK
What this proves:
- The signature was minted by the
pyde-net/enginerelease workflow (the--certificate-identity-regexp). - The signer's identity was validated by the GitHub Actions OIDC issuer (the
--certificate-oidc-issuer). - The signature covers the exact bytes of
genesis.toml(cosign re-hashes and matches).
Any one of these failing means the manifest didn't come from a real Pyde release workflow run. Don't proceed.
4. Inspect the manifest
Before you boot a validator against it, eyeball the fields:
pyde genesis validate --genesis ./genesis.toml
chain_name: pyde-testnet
chain_id: 11_155_111
chain_identity: 0x91a4...
committee_size: 7
committee: 7 founding validators (10_000 PYDE each)
prefund: 34 accounts
treasury: 0x...
epoch_length: 100 waves
dispute_window: 6 epochs
The chain_identity (a Blake3 hash over the canonical-encoded manifest) is what every other validator's consensus_store is keyed against. If yours disagrees by even one bit, the chain will refuse to peer with you.
For the published testnet, the chain_identity is also printed in the release notes on the mirror. Cross-reference them — they must match.
5. Configure your validator's network access
The release also publishes a bootnodes.txt file — a plain-text list of stable libp2p multiaddrs run by the testnet bootstrappers. Your validator dials these on first boot; once you've peered, gossipsub finds the rest of the network.
curl -fsSL -O "${BASE}/bootnodes.txt"
cat bootnodes.txt
# Pyde Testnet bootnodes — operator-run, stable across the testnet lifetime.
/dns4/bootnode-1.testnet.pyde.network/tcp/30303/p2p/12D3KooW...
/dns4/bootnode-2.testnet.pyde.network/tcp/30303/p2p/12D3KooW...
/dns4/bootnode-3.testnet.pyde.network/tcp/30303/p2p/12D3KooW...
Pass this via --bootnodes on pyde validator:
pyde validator \
--keypair ./libp2p.kp \
--falcon-keypair ./falcon.keypair --falcon-password-stdin \
--consensus-store-path ./data \
--genesis ./genesis.toml \
--bootnodes ./bootnodes.txt \
--listen /ip4/0.0.0.0/tcp/30303 \
--rpc-listen 127.0.0.1:9933 \
--falcon-beacon \
--state-sync https://state-sync.testnet.pyde.network \
--state-sync-checkpoint $(curl -fsSL "${BASE}/checkpoint.txt")
<<< 'your-falcon-passphrase'
The --state-sync-checkpoint is a weak-subjectivity gate: it pins the exact wave_id:state_root your state-sync source must produce before you'll trust its snapshot. The release publishes a fresh checkpoint at every cadence; an operator who fetches a stale checkpoint sees their validator refuse to apply the snapshot, which is correct — checkpoints are a trust narrowing, not a convenience.
See Day-2 Operations for the production setup (systemd, log rotation, monitoring) once your validator is healthy.
Where to go next
- Day-2 Operations — running the validator as a service, monitoring, log rotation, key rotation.
- Quickstart Step 5 — Register on-chain — submit your
StakeDeposittx once the validator is committee-eligible. - The State Sync companion spec — the full snapshot + tail-replay protocol.
Why this matters: the threat model
What the verification flow protects against:
| Threat | How the flow stops it |
|---|---|
| Corrupted download | SHA-256 fails → operator sees the failure, re-downloads |
| Genesis file swapped on the mirror | SHA-256 still validates against the swapped file — but the sigstore signature was minted against the original; verify-blob fails |
| Mirror compromised, signature swapped too | Sigstore certs are minted against the GitHub Actions OIDC token at workflow-run time, which is logged immutably in Rekor; cosign cross-checks. An attacker would need to compromise GitHub Actions itself + Rekor. |
| Operator skips verification, runs a swapped genesis | Their chain_identity diverges from real-network validators; gossipsub refuses the peering handshake. The chain rejects them. |
The honest gap: an attacker with full control of pyde-net/test-releases AND the ability to mint sigstore certs against pyde-net/engine's OIDC could swap the entire release. That's the same trust root as the validator binary itself — if you trust the binary you installed, the genesis bound to it has the same trust level.
Pre-mainnet that's an acceptable v1 bar. v2 hardening (multiple genesis-publisher attestations, a deterministic publish from a quorum of bootstrappers) is planned.
Day-2 Operations
Running a Pyde validator as a real service on a real machine — not a pyde validator you SIGINT when you close the laptop. This chapter covers systemd, monitoring, log rotation, encrypted-keypair workflow, and the operational hygiene that keeps you from getting slashed for downtime.
Prereqs: you've installed pyde per the Quickstart, generated keys, and joined the network per Joining a Public Testnet. Your validator boots cleanly via pyde validator … and the metrics endpoint responds. Now we make that survive the host rebooting.
1. Run pyde validator as a systemd service
The pattern: a dedicated pyde user, a service unit that supervises the process, a tmpfile for the FALCON-keypair password.
Create the service user + data dirs
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/pyde pyde
sudo mkdir -p /var/lib/pyde /etc/pyde /var/log/pyde
sudo chown -R pyde:pyde /var/lib/pyde /var/log/pyde
sudo chmod 750 /var/lib/pyde /var/log/pyde
sudo chmod 755 /etc/pyde
Move keypairs + config into place
Assuming you generated keys + downloaded genesis per the earlier chapters:
sudo cp ~/falcon.keypair /etc/pyde/falcon.keypair
sudo cp ~/pyde-testnet/genesis.toml /etc/pyde/genesis.toml
sudo cp ~/pyde-testnet/bootnodes.txt /etc/pyde/bootnodes.txt
sudo chown root:pyde /etc/pyde/falcon.keypair /etc/pyde/genesis.toml /etc/pyde/bootnodes.txt
sudo chmod 640 /etc/pyde/falcon.keypair
sudo chmod 644 /etc/pyde/genesis.toml /etc/pyde/bootnodes.txt
The FALCON keypair is 640 so only the pyde group reads it. Genesis + bootnodes are world-readable — they're public network config.
Provide the FALCON keypair password
Two options:
(a) systemd credential (recommended on systemd ≥ 250 — Ubuntu 22.04+, RHEL 9+):
sudo systemd-creds encrypt --name=falcon-password - /etc/pyde/falcon-password.cred <<< 'your-falcon-passphrase'
sudo chmod 600 /etc/pyde/falcon-password.cred
This produces a TPM-bound encrypted credential; systemd decrypts it at service-start time. The plaintext never lives on disk.
(b) Plain file with strict permissions (older systemd):
echo 'your-falcon-passphrase' | sudo tee /etc/pyde/falcon-password >/dev/null
sudo chown root:pyde /etc/pyde/falcon-password
sudo chmod 640 /etc/pyde/falcon-password
Less ideal — the password lives in plaintext on disk and is only protected by file perms. Acceptable for testnet operations; production should prefer (a) or an external secret manager.
Write the service unit
sudo tee /etc/systemd/system/pyde-validator.service >/dev/null <<'EOF'
[Unit]
Description=Pyde Validator
Documentation=https://book.pyde.network/validator/operations.html
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pyde
Group=pyde
# Hardening — defense in depth.
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/pyde /var/log/pyde
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=false
SystemCallArchitectures=native
# Resource limits.
LimitNOFILE=65536
# systemd credential — pipes to pyde's stdin so --falcon-password-stdin works.
LoadCredentialEncrypted=falcon-password:/etc/pyde/falcon-password.cred
ExecStart=/bin/bash -c 'cat "${CREDENTIALS_DIRECTORY}/falcon-password" | /usr/local/bin/pyde validator \
--keypair /var/lib/pyde/libp2p.kp \
--falcon-keypair /etc/pyde/falcon.keypair \
--falcon-password-stdin \
--consensus-store-path /var/lib/pyde/data \
--genesis /etc/pyde/genesis.toml \
--bootnodes /etc/pyde/bootnodes.txt \
--listen /ip4/0.0.0.0/tcp/30303 \
--rpc-listen 127.0.0.1:9933 \
--falcon-beacon'
# Logging — to journald, structured.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=pyde-validator
# Restart policy — chain bugs that crash hard should self-recover.
# But back off enough that a tight crash loop doesn't churn the log + the chain.
Restart=on-failure
RestartSec=10
StartLimitIntervalSec=300
StartLimitBurst=5
[Install]
WantedBy=multi-user.target
EOF
If you chose plain-file password (option b), replace the LoadCredentialEncrypted + the cat "${CREDENTIALS_DIRECTORY}/…" invocation with:
ExecStart=/bin/bash -c 'cat /etc/pyde/falcon-password | /usr/local/bin/pyde validator …'
Also install the binary to a system path so the unit's absolute path resolves:
sudo cp ~/.pyde/bin/pyde /usr/local/bin/pyde
sudo chown root:root /usr/local/bin/pyde
sudo chmod 755 /usr/local/bin/pyde
Start the service
sudo systemctl daemon-reload
sudo systemctl enable --now pyde-validator
sudo systemctl status pyde-validator
Check logs:
sudo journalctl -u pyde-validator -f
You should see the validator-boot banner, then wave-commit lines flowing.
2. Set up monitoring
The validator exposes a Prometheus /metrics endpoint on the same RPC port (127.0.0.1:9933/metrics). Scrape it from a Prometheus instance — run one on the same host or on an adjacent monitoring server.
Minimal Prometheus scrape config
# /etc/prometheus/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'pyde-validator'
metrics_path: '/metrics'
static_configs:
- targets: ['127.0.0.1:9933']
labels:
validator: 'mainnet-1' # adjust per your operator identity
The validator exposes ~40 counters covering vertex flow, batch flow, mempool flow, wave-commit progress, beacon assembly, finality, slashing-consumer outcomes, DKG attestations, and the receipt-gossip + wave-commit-gossip pipelines. See the Companion Spec on metrics for the full enumeration.
Grafana dashboards
JSON dashboard templates ship at pyde-net/test-releases:engine/grafana/. Import via Grafana's UI (Dashboards → Import → upload JSON). The templates cover:
- Validator health — vertex production rate, wave-commit cadence, mempool depth, gossipsub mesh size.
- Consensus participation — DKG attestations sent/received, beacon shares emitted/combined, state-root sigs.
- Operational counters — RPC request rate, restart count, disk usage.
Alerting — the chain-halt signal
The single most important alert: waves_committed stops climbing. If your validator's wave counter hasn't ticked in 2 minutes, the chain has halted (or your node is silently behind) and someone needs eyes on it.
Prometheus alert rule:
# /etc/prometheus/rules/pyde-validator.yml
groups:
- name: pyde-validator
rules:
- alert: PydeChainHalted
expr: increase(pyde_node_waves_committed_total[2m]) == 0
for: 2m
labels:
severity: page
annotations:
summary: "Pyde chain halt — wave_id frozen on {{ $labels.validator }}"
description: |
The validator's waves_committed counter has not advanced in 2 minutes.
Investigate: is the local validator alive? Is the network reaching consensus?
Runbook: https://book.pyde.network/companion/CHAIN_HALT.html
- alert: PydeSlashFailureDetected
expr: increase(pyde_node_dkg_participation_failures_detected_total[1h]) > 0
for: 5m
labels:
severity: warn
annotations:
summary: "DKG participation failure — slash candidate on {{ $labels.validator }}"
description: |
The DKG participation detector has registered a participation failure.
You may be missing DKG attestation submissions; this leads to slashing.
- alert: PydeMempoolBackpressure
expr: pyde_node_mempool_txs_received_total - pyde_node_mempool_txs_persisted_total > 1000
for: 5m
labels:
severity: warn
annotations:
summary: "Mempool backpressure on {{ $labels.validator }}"
description: |
The mempool is admitting more txs than it persists. Check disk i/o,
consensus-store growth, or mempool capacity tuning.
Wire the alerts to whatever paging system you have (PagerDuty, OpsGenie, ntfy, an SMS bridge, a Discord webhook — the SR side is the same).
3. Log rotation
systemd's journald handles rotation by default — the journal file caps at ~10% of disk space and the oldest entries get evicted. Tune the cap if you want shorter retention:
sudo sed -i 's/^#SystemMaxUse=.*/SystemMaxUse=2G/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
If you want plain log files (for shipping to an external log aggregator), redirect StandardOutput= in the service unit to a file:
StandardOutput=append:/var/log/pyde/validator.log
StandardError=append:/var/log/pyde/validator.log
Then ship via logrotate:
sudo tee /etc/logrotate.d/pyde-validator >/dev/null <<'EOF'
/var/log/pyde/validator.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 pyde pyde
sharedscripts
postrotate
systemctl kill --signal=USR1 pyde-validator.service 2>/dev/null || true
endscript
}
EOF
The kill -USR1 is a no-op for pyde validator today (it reads stderr directly), but the rotation works regardless because logrotate truncates atomically with copytruncate if needed.
4. FALCON keypair lifecycle
Your FALCON keypair is the single secret controlling your validator's stake. Treat it the way you'd treat a hardware-wallet seed.
At-rest encryption
The keypair file is encrypted with Argon2id + ChaCha20-Poly1305 — the password you supplied to pyde keys generate --password-stdin. Without the password the file is opaque.
Backups
The 32-byte FALCON seed is recoverable from any byte-identical copy of the keypair file. Back it up:
# Encrypted backup on offline storage (USB drive, paper key, etc.)
sudo cp /etc/pyde/falcon.keypair /mnt/usb/pyde-validator-backup.keypair
Test the backup by loading it in a sandbox:
pyde keys inspect /mnt/usb/pyde-validator-backup.keypair
# Should print the same pubkey + address as your live keypair
Rotation
When you rotate the FALCON key (compromise scare, scheduled rotation, etc.), generate the new keypair off-validator + submit a RotateValidatorKeys tx signed by the OLD key:
pyde keys generate --out ./falcon-new.keypair --password-stdin <<< 'new-passphrase'
pyde stake rotate \
--rpc http://127.0.0.1:9933 \
--falcon-keypair /etc/pyde/falcon.keypair --falcon-password-stdin \
--new-pubkey-from ./falcon-new.keypair \
<<< 'old-passphrase'
After the tx confirms (a few wave commits), swap the new keypair into place:
sudo systemctl stop pyde-validator
sudo mv /etc/pyde/falcon.keypair /etc/pyde/falcon.keypair.old
sudo cp ./falcon-new.keypair /etc/pyde/falcon.keypair
sudo chown root:pyde /etc/pyde/falcon.keypair
sudo chmod 640 /etc/pyde/falcon.keypair
# Re-encrypt the password credential with the new passphrase
sudo systemd-creds encrypt --name=falcon-password - /etc/pyde/falcon-password.cred <<< 'new-passphrase'
sudo systemctl start pyde-validator
The .old file can be archived for incident-response purposes, then securely destroyed.
Compromise recovery
If you believe the FALCON keypair is compromised, rotate immediately as above; the rotation tx is signed by the old key so an attacker with the same key COULD also rotate. The race is yours to win.
If you've already lost custody (the attacker submitted a RotateValidatorKeys first), your stake is gone — there's no recovery once the chain accepts a new pubkey. This is the same trust model as any FALCON-secured chain.
5. Disk planning
Pyde stores three growing artifacts on disk:
consensus_store— receipts, txs, wave-commit records. Grows monotonically. ~2 GB/week at testnet cadence; pruning is a v2 feature.state_store— JMT slots, account blobs, events. Also monotonic until pruning lands. ~1 GB/week./var/log/pyde+ journald — bounded by the rotation / journald cap above.
Plan for ~50 GB free disk at minimum for a 3-month testnet operation. SSD strongly recommended — RocksDB's write amplification hits spinning rust hard.
6. Firewall config
The validator needs:
- Inbound TCP 30303 (or your
--listenport) — peers dial you here. - Outbound TCP 30303 to bootnodes + peers —
pyde validatorinitiates the gossipsub mesh. - Outbound HTTPS to your state-sync source (first boot only — for the snapshot fetch).
RPC (127.0.0.1:9933) stays loopback. Never expose RPC publicly without a TLS-terminating reverse proxy + per-method auth — v1 RPC has no auth and accepts pyde_sendRawTransaction from any caller.
Sample ufw:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 30303/tcp # libp2p
sudo ufw enable
If you front the RPC behind nginx for monitoring access from an adjacent host, terminate TLS + add a proxy_pass for /metrics only.
Where to go next
- Quickstart Step 7 — Day-2 ops surface for the
pyde stakesubcommand reference. - The Chain Halt & Recovery companion spec for what to do when alerts fire.
- The Slashing companion spec for the full enumeration of slashable offenses + how to avoid them.
Otigen Toolchain Guide
A linear walkthrough for writing, testing, building, deploying, and operating Pyde contracts. Reads top-to-bottom; each chapter ends where the next begins.
If you've used Foundry, the shape will feel familiar — otigen is Pyde's equivalent: one binary that owns scaffold → write → test → deploy → read → operate. The differences are Pyde-specific: WebAssembly contracts instead of Solidity, FALCON-512 post-quantum signatures instead of secp256k1, and four supported languages (Rust, AssemblyScript, TinyGo, C) instead of one.
Who this guide is for
- You've written contracts before (Solidity / Vyper / Move / Ink! / CosmWasm / Stylus / Stellar Soroban). You know what
transfer,balance,caller, andrevertmean. You don't need a Computer-Science-101 detour. - You're new to WebAssembly but you know one of the four languages we support. We won't walk you through the WASM bytecode; we will walk you through how Pyde's host ABI shapes the way you write code in your language.
- You're a chain engineer evaluating Pyde. Skim §1–§2 to anchor; then jump to Your First Contract for a working "deploy + read state" loop in 10 minutes.
If you're brand-new to programming, this isn't the entry point — see the Get Started — for Users page first.
What the toolchain does
otigen is one binary that owns the entire authoring lifecycle:
| Subcommand | What it does |
|---|---|
otigen new | Scaffold a single contract — minimal counter by default, or clone a canonical example (counter, erc20-token, vesting, dao-governance, …) with --from <name>. Run inside a workspace to add the contract as a new contracts/<name>/ member (registered in [workspace].members + order). |
otigen init | Scaffold a new multi-contract workspace: a root otigen.toml with a [workspace] table, root .gitignore / README.md / Makefile, and a starter member at contracts/counter/. See Workspaces. |
otigen addresses | List a workspace's deployed member addresses (from artifacts/deployments/<network>.json). |
otigen check | Validate the project without packaging. Fast pre-commit gate. |
otigen build | Validate + package the compiled .wasm into a deploy bundle. Injects the pyde.abi custom section. |
otigen test | Run .test.toml declarations through the chain's wasm-exec engine. Same code path mainnet uses. |
otigen wallet | Manage FALCON-512 keystore. new / import / list / show / password / export / delete / sign / verify. |
otigen deploy | Sign + submit a deploy transaction, poll for the receipt. At a workspace root: builds every member, prints a deploy plan, deploys in [workspace].order resolving @name cross-references, skips already-deployed members, and caches addresses. --contract <name> (also on build / test) scopes to one member. |
otigen call | Invoke a function on a deployed contract. Typed positional args (decoded per [functions.<fn>].inputs), wallet-name address resolution, optional --value <quanta> PYDE transfer. View mode is free; --from switches to a state-mutating signed tx. |
otigen send | Native PYDE value transfer between accounts. TxType::Standard, 21,000-gas path; recipient accepts a 0x address or a wallet name. |
otigen inspect | Read contract / account metadata + state. --state-field <name> returns a typed scalar read from the declare_storage! substrate slot. |
otigen verify | Reproducibility check: fetch the on-chain bytes, recompute locally, compare. Optional --explorer upload. |
otigen upgrade / pause / unpause / kill | Lifecycle ladder. Engine-gated in v1 — refused at the CLI until the chain ships TxType::Lifecycle. See Lifecycle for the v1 patterns (proxy upgrades, author-declared pause/kill booleans). |
otigen console | Interactive REPL against a Pyde node. Persistent history, view + write calls, live event subscriptions. |
otigen devnet | Run a local devnet embedded in the otigen binary. Deterministic genesis pre-fund (10 accounts auto-imported into ~/.pyde/keystore.json as devnet-0..devnet-9). --fork bootstraps state from a snapshot file or pyde_getSnapshot URL. |
otigen validator | Read-only queries over the chain-side validator registry. show <addr> fetches one validator's full record (stake / status / jail / uptime); by-operator <addr> lists every validator an operator runs. |
otigen update | Pull the latest release and replace the binary. Wraps the canonical curl install one-liner; --check prints latest-vs-installed without side effect. |
otigen is the only binary you need. There is no otigen-deploy, no otigen-test, no separate SDK. One tool, four languages, complete coverage.
For exhaustive flag + arg reference, see Commands.
What the toolchain is NOT
- Not a smart contract language. Pyde contracts are written in real Rust / TinyGo / AssemblyScript / C, compiled to WebAssembly by each language's own compiler.
otigenvalidates + packages the output. The chain runs the WASM. - Not a runtime.
otigen testexecutes through the chain'spyde-engine-wasm-exec::WasmExecutorby default (same code path mainnet uses); the legacy in-process mock is opt-in via--no-enginefor the handful of cases the engine can't yet host (parachains, today). - Not an SDK. For Rust, the
#[pyde::entry]macro +pyde::declare_storage!()substrate (pulled from crates.io —pyde-hostplus the macro crates) is the canonical authoring path; for the other three languages, authors declarepyde::*host fn imports directly via the language's FFI mechanism (//go:wasmimport,@external,__attribute__((import_module))). See WASM Contract Author Guide for the design rationale. - Does not bundle a language compiler.
otigen buildinvokescargo/tinygo/asc/clangfrom your environment. You install the language toolchain yourself.
Supported languages
All four are first-class. Pick the one your team is most productive in; the size / gas deltas matter less than people-hours saved.
| Language | WASM size (counter) | Notes |
|---|---|---|
| C | ~1.0 KB | Smallest + fastest. Bare-metal feel, no runtime; you manage memory yourself. Pick when binary size or per-call gas is critical. |
| Rust | ~5.0 KB (with macro substrate) | Most ergonomic. #![no_std] + #[pyde::entry] macro + pyde::declare_storage!() for typed schema access. Default recommendation for production contracts. |
| AssemblyScript | ~3.0 KB | TypeScript-shaped syntax. Higher per-call gas because of runtime array-bounds checks. Pick when TS familiarity outweighs the cost. |
| TinyGo | ~60 KB | Go ecosystem. Heavier binary due to runtime overhead. Pick when sharing code with off-chain Go services. |
The counter contract ships in each language under otigen/examples/. The counter-rust, counter-go, counter-as, and counter-c directories each carry a working build — clone any of them, run make build && make test, see it work.
The development arc
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 1. SCAFFOLD │→│ 2. WRITE │→│ 3. TEST │
│ otigen new │ │ src/lib.rs │ │ otigen test │
│ otigen init │ │ otigen.toml │ │ otigen test -vv │
│ --lang rust|as.. │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
↓
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 6. INSPECT │←│ 5. DEPLOY │←│ 4. BUILD │
│ otigen inspect │ │ otigen deploy │ │ otigen build │
│ otigen call │ │ otigen verify │ │ → bundle/ │
│ otigen verify │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │
│ ↓
│ ┌──────────────────┐
│ │ 7. LIFECYCLE │ (engine-gated in v1
│ │ proxy upgrades │ — see Lifecycle)
│ │ author-declared │
│ │ pause / kill │
│ └──────────────────┘
↓
┌──────────────────┐
│ Off-chain query │
│ otigen call <fn> │
│ (view, no tx) │
└──────────────────┘
The remaining chapters walk each step:
- Installation — toolchain setup per language.
- Commands — exhaustive subcommand + flag reference.
- Your First Contract — scaffold → write → test.
- Shipping — build → deploy.
- Inspect & Verify — inspect + reproducibility checks.
- Lifecycle — proxy upgrades, author-declared pause / kill, and the v2 chain-side plan.
- Debugging — common errors + how to recover.
- Examples — the template catalog + on-disk reference contracts.
Conventions in this guide
- Command output is shown in code blocks immediately after the command. If you run it locally and see something different, that's a bug — file an issue at https://github.com/pyde-net/otigen/issues.
<placeholders>in shell commands need to be replaced with your values.<name>is the project name,<addr>is a 32-byte0x-prefixed lowercase-hex address, and so on.- Cross-references:
HOST_FN_ABI_SPEC §7.1means §7.1 of the Host Function ABI spec. Specs are normative; this guide is pedagogical. Where the guide and the binary disagree, the binary's--helpis the runtime truth and we treat the guide as the bug. - The default language is Rust for all examples. The patterns are identical across languages; the per-language README in each
examples/counter-*directory carries the syntactic equivalent. - Mappings in
[state]schemas accept both the canonical form (type = "map", keys = ["address"], value = "uint128") and Solidity-style sugar (type = "mapping(K => V)"ormapping(K -> V), including nested forms up to 3 keys). The build lowers the sugar to the canonical form. - Signed-tx commands (
deploy/send/upgrade/pause/unpause/kill) accept--rpc-url <URL>+--chain-id <N>as a paired one-shot override of the project's[network.<name>].otigen callaccepts--rpc-urlalone (chain id is read from the resolved network). The pair is mandatory when used: a raw URL doesn't advertise a chain id, and signing againstchain_id = 0silently bricks the FALCON signature.
Installation
Two halves: the otigen binary itself, then the language toolchain for whichever of the four you'll write contracts in. Install both before continuing to the next chapter.
1. The otigen binary
Install (curl one-liner)
The canonical install path is a single command. It detects your platform, downloads the latest signed release from the public mirror, verifies the sha256, drops the binary into ~/.otigen/bin, and appends a marker-wrapped export PATH=… block to your shell rc:
curl -fsSL https://raw.githubusercontent.com/pyde-net/test-releases/main/otigen/install.sh | bash
No gh CLI required, no GITHUB_TOKEN setup, no auth dance — the release mirror at pyde-net/test-releases is public and the install script fetches anonymously over plain curl + the GitHub CDN.
Supported targets: macOS arm64, Linux x86_64, Linux aarch64, Windows x86_64. Windows users run the same script from Git Bash or WSL.
Open a new terminal afterwards (so the PATH update takes effect), then confirm:
otigen --version
otigen 0.1.0 (sha be73970a, release)
The version line carries the git SHA + build profile so two contributors can compare binaries when something looks wrong.
Pin a specific version
curl -fsSL https://raw.githubusercontent.com/pyde-net/test-releases/main/otigen/install.sh \
| bash -s -- --version v0.1.0-alpha.1
Pass either the bare version (v0.1.0-alpha.1) or the full mirror tag (otigen-v0.1.0-alpha.1) — both are accepted. Useful for testing, rollback, or reproducibility; pre-release tags work too.
Update
Easiest: let the CLI do it.
otigen update # latest
otigen update --check # poll without side effect (exit 1 = drift)
otigen update wraps the canonical curl one-liner so you don't have to dig the URL out each time. Same script, same target detection, same sigstore verification.
If you don't have otigen on PATH yet (fresh box, broken install), re-run the canonical one-liner directly:
curl -fsSL https://raw.githubusercontent.com/pyde-net/test-releases/main/otigen/install.sh | bash
Idempotent — re-running over an up-to-date install is a no-op on the shell rc.
Uninstall
curl -fsSL https://raw.githubusercontent.com/pyde-net/test-releases/main/otigen/install.sh \
| bash -s -- --uninstall
Removes the binary, strips the marker-wrapped PATH block from every shell rc that has it (~/.zshrc, ~/.bashrc, ~/.bash_profile, ~/.config/fish/config.fish are all scanned), and rmdirs ~/.otigen/bin if empty.
Install-script flags
Pass any of these via bash -s -- <FLAGS>:
| Flag | What it does |
|---|---|
--update | Explicit alias for the default install-or-replace behavior. |
--uninstall | Remove binary + clean shell rc + drop empty install dir. |
--version <TAG> | Pin a specific release tag instead of the latest. Accepts vX.Y.Z or otigen-vX.Y.Z. |
--prefix <DIR> | Install location override. Default ~/.otigen/bin; also honours OTIGEN_INSTALL_DIR env var. |
--no-modify-path | Skip the shell-rc PATH edit. For users with managed dotfile repos. |
--no-verify-sig | Skip sigstore-keyless signature verification of the downloaded asset (default: verify when cosign is on PATH). |
--check-only | Dry run — print what the script would do and exit. Works with any mode. |
-h / --help | Full catalog. |
Manual download
If you'd rather skip the script, grab the per-platform tarball directly from the public mirror's release page:
# Replace v0.1.0-alpha.1 with the current release tag, and the target triple
# (aarch64-apple-darwin / x86_64-unknown-linux-gnu / aarch64-unknown-linux-gnu /
# x86_64-pc-windows-msvc) with your platform. The mirror prefixes every
# otigen release tag with `otigen-`, so the lookup is `otigen-<tag>`.
gh release download otigen-v0.1.0-alpha.1 --repo pyde-net/test-releases \
--pattern 'otigen-v0.1.0-alpha.1-aarch64-apple-darwin.tar.gz' \
--pattern 'otigen-v0.1.0-alpha.1-aarch64-apple-darwin.tar.gz.sha256'
shasum -a 256 -c otigen-v0.1.0-alpha.1-aarch64-apple-darwin.tar.gz.sha256
tar xzf otigen-v0.1.0-alpha.1-aarch64-apple-darwin.tar.gz
sudo install -m 0755 \
otigen-v0.1.0-alpha.1-aarch64-apple-darwin/otigen \
/usr/local/bin/
Anonymous curl -L against the asset's browser_download_url works the same way for users without gh installed.
Every release publishes binaries for all four platforms, each accompanied by:
.sha256— checksum (auto-verified by the install script)..sig+.pem— sigstore-keyless OIDC signature + certificate. The install script doesn't currently verify these (cosign is an optional install on the user side); manual verification flow lives in the mirror README and is normatively specified inOTIGEN_BINARY_SPEC §11.4.
Build from source
For contributors and bleeding-edge users. While the source repos are private during pre-mainnet engineering, sibling-clone access requires Contents:read on each:
git clone https://github.com/pyde-net/otigen
git clone https://github.com/pyde-net/engine # sibling — path-dep'd by otigen-cli
git clone https://github.com/pyde-net/pyde-crypto # sibling — also path-dep'd
cd otigen
cargo build --release -p otigen-cli
sudo install target/release/otigen /usr/local/bin/
Installs to /usr/local/bin/otigen. Requires Rust ≥ 1.93 (cranelift transitive dep). The three sibling repos are needed because the otigen workspace path-deps into both engine/ and pyde-crypto/.
Once the source repo flips public for v1, the same make install (or cargo install --path crates/otigen-cli) flow works from a public clone with no auth.
Note: make install and cargo install --path … write to ~/.cargo/bin/otigen (cargo's standard target), not /usr/local/bin/otigen. If you've previously used the curl one-liner or the sudo install step above, ~/.cargo/bin must appear earlier on PATH for the new binary to win.
2. Language toolchain
Install only the one(s) you'll use. Each language's Makefile (generated by otigen init or otigen new) has a make check-tools target that verifies the chain is set up correctly.
Rust
# rustup gives you the compiler + the wasm32 target.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
Required version: Rust ≥ 1.93 (matches the rust-version floor in the workspace Cargo.toml; raised from 1.87 when wasmtime 45's cranelift transitive deps pushed the MSRV up).
Verify:
rustup show | grep -E "active toolchain|wasm32"
Common errors: see install-gotchas — TL;DR: forget the wasm32-unknown-unknown target and you get cryptic linker errors at the first cargo build.
TinyGo
# macOS
brew tap tinygo-org/tools
brew install tinygo go binaryen
# Linux (apt)
apt install tinygo golang binaryen
Three packages are required:
- TinyGo — the wasm32 compiler.
- Go — TinyGo bundles its own Go compiler fork but also needs a standard Go install for module resolution. Without it,
tinygo versionreports(using go version <unknown>)and module resolution misbehaves silently. - binaryen — ships
wasm-opt, which TinyGo invokes for size optimisation under the-opt=zflag the otigen scaffold uses. Without it, any compile path (otigen build/otigen test/otigen check) fails fast with a platform-tagged install hint (ToolchainMissing: TinyGo requires `wasm-opt` (binaryen) for size optimisation).
Required versions: TinyGo ≥ 0.41, Go ≥ 1.21 (for //go:wasmimport), binaryen ≥ 116 (anything wasm-opt --version reports works in practice).
The wasm-unknown target landed in TinyGo 0.31, but the otigen Go scaffold + canonical examples are tested against the 0.41 series — earlier versions hit //go:wasmexport codegen bugs that landed fixes in 0.34 / 0.36 / 0.41. The scaffold's otigen.toml pins tinygo_version = "0.41.0"; older toolchains aren't supported.
Verify:
tinygo version
wasm-opt --version
tinygo version 0.41.1 darwin/arm64 (using go version go1.26.3 and LLVM version 20.1.1)
wasm-opt version 130
Common errors:
brew install tinygo(without the tap) fails with "no available formula." You mustbrew tap tinygo-org/toolsfirst.could not find wasm-opt, set the WASMOPT environment variable to override: binaryen isn't installed.brew install binaryen(macOS) /apt install binaryen(Debian) /pacman -S binaryen(Arch). To point TinyGo at a custom build, setWASMOPT=/path/to/wasm-optin your shell.- First
tinygo buildafterotigen initfails witherror obtaining VCS status: exit status 128if the project dir isn't a git repo. Fix:git init -qinside the project.
AssemblyScript
# macOS
brew install node
# Or any Node ≥ 18 install:
# https://nodejs.org/en/download
# Then, per-project:
cd <project-dir> && npm install
# (uses the local `assemblyscript` devDependency from package.json)
Or install globally:
npm install -g assemblyscript
Required versions: Node ≥ 18, AssemblyScript ≥ 0.28.
Verify:
node --version
asc --version
Common errors:
asc: command not foundafternpm install -g: your npm global prefix isn't on$PATH. Checknpm config get prefixand add<prefix>/bintoPATH. Or use the local install vianpm run build.- Compile fails with
env.abort import is forbidden: someone removed theuse: ["abort=..."]line inasconfig.json. See Debugging.
C / C++
# macOS — Apple's bundled clang lacks the wasm32 backend.
# Install brew's LLVM + lld:
brew install llvm lld
# Add to your shell profile (~/.zshrc or similar):
echo 'export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/lld/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
# Linux: clang + lld from apt usually ships wasm32 ready.
apt install clang lld
Required components: clang with the wasm32 backend (verify with clang -print-targets | grep wasm32) AND wasm-ld (LLVM's WASM linker, lld package).
Verify:
clang -print-targets | grep wasm32
which wasm-ld
Common errors:
error: unable to create target: 'No available targets are compatible with triple "wasm32"': you're using Apple's/usr/bin/clangwhich lacks the wasm32 backend. Install brew's LLVM and updatePATH.clang: error: unable to execute command: posix_spawn failed: No such file or directorywhen linking:wasm-ldis missing.brew install lldseparately — it's NOT pulled in bybrew install llvm.MakefileusesclangfromPATH. Ifwhich clangresolves to Apple's, the build fails. Either re-order yourPATHor override per-build:make CC=/opt/homebrew/opt/llvm/bin/clang build.
3. Verify everything together
The fastest end-to-end smoke test — otigen test auto-invokes the per-language compiler before running the suite, so a single command covers build + test:
otigen new smoke-test --lang rust --from counter
cd smoke-test
otigen test
→ Compiling (rust) — cargo build --target wasm32-unknown-unknown --release
✓ Compiled → ./target/wasm32-unknown-unknown/release/smoke_test.wasm
Running 3 tests in ./tests/contract.test.toml (via engine)
✓ get_returns_zero_initially (29.55 ms)
✓ increment_advances_by_one (7.72 ms)
✓ three_increments_yield_three (6.82 ms)
test result: ok. 3 passed; 0 failed; 0 skipped (3 ran)
First-run timings include the full release compile (~10–30 s on a small Rust contract); subsequent runs hit cargo's incremental cache and finish in <1 s.
If you get that output, you're ready for the next chapter. If not, the error message tells you which piece is missing — most install issues route to a command not found or a clear missing-target message; cross-check against the per-language notes above.
Reference
- Full per-language install gotchas with troubleshooting steps: Debugging — installation errors.
- Toolchain pinning for reproducible builds: each project's
otigen.tomlrecordsrust_channel/tinygo_version/asc_version/clang_version. The chain doesn't enforce these, but your team should. For team-wide enforcement on audit machines,otigen verify --strict-toolchain <addr>cross-checks the bundle's declared toolchain versions against the host's installed compilers and fails on mismatch. - The
make check-toolstarget inside each scaffolded project verifies all four prerequisites are present + correct. - Public release mirror:
pyde-net/test-releases— README covers the tag convention, manual sigstore verification, and the canonical surfaces for every Pyde toolchain.
Commands reference
Every subcommand the otigen binary exposes — what it does, what it accepts, what it prints. For the formal contract on flag values, exit codes, and event streams, see OTIGEN_BINARY_SPEC.md. (The spec is the authoritative source; where this chapter and the spec disagree, the binary's --help output is the runtime truth and the spec is the one being chased.)
Global flags apply to every subcommand:
| Flag | Default | What it does |
|---|---|---|
-v / -vv | off | Verbose / debug-level log output. -v enables INFO, -vv adds DEBUG. |
-q / --quiet | off | Suppress non-error output. |
--json | off | Emit structured NDJSON events to stdout (one event per line) — for CI / scripting consumers. |
--network <NAME> | [network.default] in otigen.toml | Override the network selected by the manifest. |
--keystore <PATH> | ~/.pyde/keystore.json | Override the default keystore path. |
--config <PATH> | ./otigen.toml | Override the default config path. |
Path defaults that say <config-dir>/... are resolved against the parent of --config, not the cwd. Invocations from outside the project tree therefore find / write bundles next to the project.
otigen new
Scaffold a single contract. By default it's a minimal counter; --from <template> clones a canonical example instead. Run inside a workspace to add the contract as a new member (see otigen init and Workspaces).
otigen new [OPTIONS] [NAME]
otigen new --list # show the template catalog
| Argument / flag | Type | Default | What it does |
|---|---|---|---|
[NAME] | string | prompt on TTY | Project name (ENS-style: lowercase + hyphens, 1–32 chars). |
--lang <LANG> | enum | prompt on TTY | Target language (rust, as, go, c). Only Rust has canonical example templates; --lang go / as / c scaffold the language's minimal counter starter. |
--from <TEMPLATE> | name | minimal counter | Canonical example to clone (Rust). otigen new --list shows what's available (currently 8: counter, erc20-token, erc721-token, simple-multisig, upgradeable-proxy, merkle-claim-airdrop, vesting, dao-governance). Omitted ⇒ minimal counter. |
--list | — | — | Print the template catalog and exit. Mutually exclusive with <NAME> / --lang / --from / --dir. |
--dir <DIR> | path | ./<name> | Target directory. Created if missing; refuses to overwrite an existing path. |
otigen new --list
otigen new my-counter --from counter
otigen new my-token --from erc20-token --dir ./projects/my-token
The scaffold is a full single-contract project tree (Cargo.toml, otigen.toml, src/, tests/, Makefile). Run from inside a workspace, otigen new <name> instead scaffolds the contract under contracts/<name>/ and registers it in the root manifest's [workspace].members + order.
otigen init
Scaffold a new multi-contract workspace — a project that groups several contracts which build, test, and deploy together. See Workspaces for the full flow.
otigen init [OPTIONS] [NAME]
| Argument / flag | Type | Default | What it does |
|---|---|---|---|
[NAME] | string | prompt on TTY | Workspace name (ENS-style: lowercase + hyphens, 1–32 chars). |
--lang <LANG> | enum | prompt on TTY | Language for the starter member: rust, as (AssemblyScript), go (TinyGo), c (clang --target=wasm32). |
--type <TYPE> | enum | contract | contract or parachain for the starter member. Parachain projects add the §8 parachain-only host fns to the imports surface. |
--dir <DIR> | path | ./<name> | Target directory. Created if missing; refuses overwrite. |
otigen init my-app --lang rust
otigen init my-parachain --lang go --type parachain
otigen init my-c-app --lang c --dir ~/projects/my-c-app
The workspace root gets a [workspace] otigen.toml, a .gitignore, a README.md, and a Makefile. The first member — a minimal counter (increment + get) with 3 behaviour tests — lands at contracts/counter/. Add more with otigen new <name> from the root; run otigen test immediately to confirm the toolchain is wired.
otigen check
Validate the project without packaging. Fast alternative to otigen build for pre-commit hooks and IDE integrations.
otigen check [OPTIONS]
| Flag | Default | What it does |
|---|---|---|
--no-compile | off | Skip the language compiler invocation. Validates the existing .wasm as-is. |
Runs the otigen.toml parser + WASM validator + ABI extractor. Skips the bundle write. Typical latency on a small contract: tens of milliseconds.
otigen check
otigen check --no-compile
otigen build
Validate + package the compiled .wasm into a deploy bundle.
otigen build [OPTIONS]
| Flag | Default | What it does |
|---|---|---|
--release | (currently no-op) | Reserved for future release-build validation. Today both --release and bare otigen build produce the same bundle; the debug-vs-release split is enforced at deploy time only. |
--debug | off | Compile with debug profile. Reject at deploy time, useful for inspection only. |
--no-compile | off | Skip the language compiler — package the existing .wasm as-is. |
--no-strict | off | Disable the production gate that rejects test-only host fns (e.g. pyde::debug_log). Default is strict so the bundle is chain-deploy-safe. Escape hatch for bundling chain-unsafe wasm for local inspection / fuzzing; never use for a bundle that will reach a network. |
--out <PATH> | <config-dir>/artifacts/ | Override the bundle output directory — <name>.bundle/ is created inside it. Anchored on the parent of --config so invocations from outside the project dir (otigen --config path/to/otigen.toml build) write next to the project, not the cwd. |
--contract <NAME> | all members | In a workspace, build only this member (by [contract].name). Errors in a single-contract project. |
At a workspace root, otigen build builds every member into the shared artifacts/ directory (and prunes bundles for removed members); --contract <name> scopes to one. See Workspaces.
Output bundle lands at --out (default <config-dir>/artifacts/<name>.bundle/) with:
contract.wasm— the compiled binary (blake3-checksummed).abi.json— the contract's ABI extracted from[functions.*].manifest.json— canonical manifest snapshot (build-deterministic apart from abuild_timestampfield).metadata.json— JSON-extracted[metadata]section (the project's source URL, license, description). Hashed intomanifest.metadata_hash_blake3for explorer verification.otigen.toml— the source manifest copied verbatim sootigen deploy/verify/testcan re-read declarations from a bundle without the source tree.
Exits non-zero on validation failure (VALIDATION_FAILURE = 1). Scripts can rely on the exit code.
otigen build
otigen build --json # NDJSON event stream for CI
otigen build --no-compile # repackage existing wasm
otigen test
Run contract behaviour tests declared in tests/*.test.toml.
otigen test [OPTIONS]
| Flag | Default | What it does |
|---|---|---|
--dry-run | off | Parse + resolve only — no WASM execution. Useful for validating a .test.toml against the contract's [state] schema. |
--filter <SUBSTR> | none | Substring filter — only tests whose name contains this pattern run. Repeating the flag last-wins. |
--bundle <DIR> | ./artifacts/<name>.bundle/ | Override the bundle directory. Parity with deploy --bundle / verify --bundle. |
--watch | off | Re-run on file change. Debounced 300 ms; ignores target/, artifacts/, .git/, node_modules/, build/, dist/. |
--no-engine | off | Use the legacy in-process mock host-fn surface instead of pyde-engine-wasm-exec::WasmExecutor. The engine path is the default and source of truth. |
--no-compile | off | Skip the per-language compiler. Run the test suite against the existing .wasm as-is. |
--contract <NAME> | all members | In a workspace, test only this member (by [contract].name). Errors in a single-contract project. |
At a workspace root, otigen test builds + tests every member (a member with no test file is skipped, not failed) and prints a workspace summary; --contract <name> scopes to one. --watch isn't supported at the workspace level. See Workspaces.
Verbosity is the standard global -v flag, repeated for more detail:
otigen test # default — per-test pass/fail + duration
otigen test -v # + INFO logs
otigen test -vv # + DEBUG (host-fn calls, slot derivations)
otigen test --json # NDJSON event stream
For per-call assertions + storage-diff rendering, declare them in [tests.expect] / expect.* in the test TOML; failures print the expected-vs-actual. See OTIGEN_TEST_SPEC.md for the test DSL.
otigen wallet
Manage FALCON-512 signing accounts in ~/.pyde/keystore.json.
otigen wallet <ACTION> [OPTIONS]
Subactions: new, import, list, show, delete, password, export, sign, verify.
wallet new
Create a fresh keypair, prompt for a password, encrypt + store.
otigen wallet new [NAME] [--password-stdin]
[NAME] is positional and optional — omitted prompts on a TTY (errors under --json or piped stdin). --password-stdin reads the encryption password from stdin (two consecutive lines — password + confirmation — or a single line treated as both).
otigen wallet new deployer
printf 'pw\npw\n' | otigen wallet new alice --password-stdin
wallet import
Import an existing keypair into the keystore. Three modes:
otigen wallet import [NAME] # interactive: paste pubkey + secret key
otigen wallet import --from-file <PATH> <NAME> # restore a wallet export
otigen wallet import --from-devnet [--prefix <P>] [--count <N>] [--password-stdin]
| Flag | Default | What it does |
|---|---|---|
--from-file <PATH> | none | Read a backup JSON (otigen wallet export output). The original password still decrypts it. |
--from-devnet | off | Bulk-import the 10 deterministic prefunded pyde devnet accounts (Blake3("pyde-devnet-v1/" || i)). Public secrets — good for tests, bad for real value. |
--prefix <PREFIX> | devnet- | Name prefix used when --from-devnet is set. Imports as <prefix>0..<prefix>9. |
--count <N> | 10 | Number of prefunded accounts to import under --from-devnet. |
--password-stdin | off | Read the encryption password from stdin. Currently honoured under --from-devnet only; interactive imports still use the rpassword prompt. |
printf 'pw\npw\n' | otigen wallet import --from-devnet --password-stdin
otigen wallet import --from-file ./alice.backup.json alice
wallet list
otigen wallet list
Print every account name + address in the keystore. Touches no encrypted material.
wallet show
otigen wallet show <NAME>
Print one account's address + public key. No password needed.
wallet delete
otigen wallet delete <NAME> [--yes]
Remove an account. Asks for confirmation (re-type the account name) unless --yes.
wallet password
otigen wallet password <NAME>
Re-encrypt the account under a new password. The keypair itself is unchanged. Password rotation requires a real PTY today — no --password-stdin on this subcommand yet.
wallet export
otigen wallet export <NAME> [--out <PATH>]
Export the account as a portable encrypted backup. Same Argon2id + AES-256-GCM ciphertext as the in-keystore entry; the original password decrypts it. No password prompt — the export ciphers-as-is. Restore later with wallet import --from-file.
otigen wallet export alice --out ./alice.backup.json
otigen wallet export alice > ./alice.backup.json
wallet sign
FALCON-512 sign arbitrary message bytes. For off-chain attestations / signing challenges. Don't use for chain transactions — deploy / upgrade / call sign the canonical Poseidon2 tx hash, which is what the chain verifier expects.
otigen wallet sign [OPTIONS] --message <MESSAGE> [NAME]
| Flag | Default | What it does |
|---|---|---|
-m, --message <MSG> | required | Message to sign. UTF-8 by default; with --hex decoded as hex. |
--hex | off | Decode --message as hex (0x-prefix optional). |
--password-stdin | off | Read the wallet password from stdin. |
otigen wallet sign devnet-0 --message "hello world"
otigen wallet sign devnet-0 --message 0xdeadbeef --hex --password-stdin <<< pw
wallet verify
Verify a signature against a message + public key. Exit code is the verdict: 0 on valid, 1 on invalid.
otigen wallet verify [OPTIONS] [NAME] --message <MSG> --signature <HEX>
| Flag | Default | What it does |
|---|---|---|
[NAME] | none | Wallet name whose public key signs. Mutually exclusive with --pubkey. |
--pubkey <HEX> | none | Verify against an arbitrary public key (e.g. a counterparty's). |
--message <MSG> | required | Message that was signed. UTF-8; pass --hex for binary. |
--signature <HEX> | required | The signature output by wallet sign. |
--hex | off | Decode --message as hex. |
otigen wallet verify devnet-0 --message "hello world" --signature 0x...
otigen wallet verify --pubkey 0x09... --message 0xdeadbeef --hex --signature 0x...
otigen deploy
Sign and submit a deploy transaction.
otigen deploy [OPTIONS]
| Flag | Default | What it does |
|---|---|---|
--bundle <PATH> | <config-dir>/artifacts/<name>.bundle/ | Bundle directory to deploy. Anchored on the parent of --config so deploys from outside the project dir find the bundle next to the project. |
--from <WALLET> | [wallet.default_account] | Signing account. |
[ARGS...] | none | Typed positional constructor args, marshalled per the constructor's declared inputs — the [functions.<name>] entry tagged constructor, any name — in declaration order. Same encoder + wallet-name address resolution as otigen call. Mutually exclusive with --args. |
--args <HEX> | empty | Pre-encoded borsh calldata for the constructor (init), hex-encoded. Renamed from --init-arg to match otigen call --args. Mutually exclusive with positional constructor args. |
--value <QUANTA> | 0 | Optional native PYDE transfer to the freshly-deployed contract account (decimal quanta — 1 PYDE = 10⁹ quanta). The constructor sees it via pyde::ctx::value(); forfeited per PIP-4 if the constructor reverts. |
--dry-run | off | Build + sign the tx but don't submit. Useful for inspecting the wire bytes. |
--no-wait | off | Submit and exit without polling for the receipt. |
--password-stdin | off | Read wallet password from stdin. |
--rpc-url <URL> | from otigen.toml | One-shot RPC URL override. Bypasses the bundle's baked [network.<name>]. REQUIRES --chain-id. |
--chain-id <N> | from otigen.toml | Required when --rpc-url is set; the chain's tx-hash domain. The CLI refuses --rpc-url without --chain-id (signed tx against chain_id = 0 silently bricks the FALCON signature). |
--contract <NAME> | all members | In a workspace, deploy only this member (by [contract].name). Errors in a single-contract project. |
Receipt poll timeout is 60 s (constant, not CLI-configurable). On success the contract address appears in the receipt; the CLI prints it.
otigen deploy --from devnet-0 --password-stdin <<< pw
otigen deploy --from devnet-0 \
--rpc-url http://127.0.0.1:29933 \
--chain-id 31337 \
--password-stdin <<< pw
otigen deploy --dry-run # print wire bytes, don't submit
There is no --gas-limit / --gas-price flag today; values come from [deploy] in otigen.toml (gas_limit = 10_000_000, gas_price = "auto").
At a workspace root, otigen deploy prints a deploy plan (network, RPC, account, order), builds every member, then deploys each member in [workspace].order — resolving @name cross-references to deployed addresses, skipping members already registered on-chain, and caching addresses to artifacts/deployments/<network>.json. Constructor args come from [workspace.args]; with --contract <name> they may instead be given on the command line (positional or --args 0x<hex>, overriding that member's [workspace.args] entry — positional @name values still resolve), and --value funds that one member's constructor. --dry-run prints the plan and each member's resolved args without building, submitting, unlocking the wallet, or touching the RPC. See Workspaces.
otigen addresses
List a workspace's deployed member addresses, read from the deploy cache (artifacts/deployments/<network>.json) written by otigen deploy. Workspace-only — a single contract's address is printed by otigen deploy.
otigen addresses [OPTIONS]
| Flag | Default | What it does |
|---|---|---|
--network <NAME> | workspace [network.default] | Which network's deployments to list. |
Every workspace member is listed with its deployed address, or (not deployed) if it hasn't been deployed to the selected network. --json emits the raw name → address map for scripts.
otigen addresses
otigen addresses --network testnet
otigen addresses --json
otigen upgrade
Replace a contract's WASM via the upgrade flow (spec §3.4).
Status (v1): The chain has no
TxType::Lifecyclehandler yet. The CLI refuses to submit by default (EngineNotReady). See Lifecycle for the v1 proxy-pattern alternative and the--i-know-engine-rejectsbypass.
otigen upgrade [OPTIONS] <TARGET>
| Flag | Default | What it does |
|---|---|---|
<TARGET> | required | Contract name (registered) or 0x-prefixed address. |
--bundle <PATH> | ./artifacts/<name>.bundle | Bundle directory containing the new contract.wasm. Mutually exclusive with --wasm. |
--wasm <PATH> | none | Explicit path to the new .wasm. Mutually exclusive with --bundle. |
--from <WALLET> | [wallet.default_account] | Signing account. |
--no-wait | off | Submit and exit without polling. |
--password-stdin | off | Read wallet password from stdin. |
--i-know-engine-rejects | off | Bypass the EngineNotReady gate. See deploy --help-style warning above. |
--rpc-url <URL> | from otigen.toml | One-shot RPC URL override. REQUIRES --chain-id. |
--chain-id <N> | from otigen.toml | Required when --rpc-url is set. |
otigen pause / unpause / kill
Lifecycle controls (spec §3.5). Same EngineNotReady gate as upgrade.
otigen pause [OPTIONS] <TARGET>
otigen unpause [OPTIONS] <TARGET>
otigen kill [OPTIONS] <TARGET> [--yes]
All three share the same flag surface as upgrade:
| Flag | Default | What it does |
|---|---|---|
<TARGET> | required | Contract name or 0x-prefixed address. |
--from <WALLET> | [wallet.default_account] | Signing account. |
--no-wait | off | Submit and exit without polling. |
--password-stdin | off | Read wallet password from stdin. |
--i-know-engine-rejects | off | Bypass the EngineNotReady gate. |
--rpc-url <URL> | from otigen.toml | RPC override. REQUIRES --chain-id. |
--chain-id <N> | from otigen.toml | Required when --rpc-url is set. |
--yes (kill only) | off | Skip the interactive "re-type the contract name" confirmation. |
otigen pause my-counter --from devnet-0 --i-know-engine-rejects --password-stdin <<< pw
otigen unpause my-counter --from devnet-0 --i-know-engine-rejects --password-stdin <<< pw
otigen kill my-counter --from devnet-0 --i-know-engine-rejects --yes --password-stdin <<< pw
otigen call
Invoke a function on a deployed contract. View vs mutating is decided by the presence of --from: with a signing wallet the call submits a tx; without one it runs in view mode via pyde_call.
otigen call [OPTIONS] <TARGET> <FUNCTION> [ARGS...]
| Arg / Flag | Default | What it does |
|---|---|---|
<TARGET> | required | Contract name or 0x-prefixed address. At a workspace root, a member's [contract].name resolves to that member's manifest (for typed-arg encoding) over the workspace network. |
<FUNCTION> | required | Function name from the contract's ABI. |
[ARGS...] | none | Typed positional args. Marshalled per [functions.<FUNCTION>].inputs in declaration order. Mutually exclusive with --args. See "Typed arguments" below. |
--args <HEX> | none | Pre-encoded borsh calldata, hex-encoded. Escape hatch when typed args don't fit (e.g. calling a contract without a local otigen.toml). Mutually exclusive with positional ARGS. |
--raw | off | Preserve raw hex output for view-call returns. Default behaviour decodes per [functions.<FUNCTION>].outputs. |
--value <QUANTA> | 0 | Native PYDE to attach to a mutating call (quanta = 10⁻⁹ PYDE). |
--from <WALLET> | none (view mode) | Signing account. Presence flips the call to a state-mutating signed tx. |
--no-wait | off | For mutating calls: submit + exit without polling. |
--password-stdin | off | Read wallet password from stdin. |
--rpc-url <URL> | from otigen.toml | RPC override. View-mode --rpc-url does NOT require --chain-id (no tx signed). Mutating-mode calls reject --rpc-url outright (the CLI exits with CfgRequired — state-mutating calls need a local otigen.toml so the resolver can find the chain id + wallet defaults). Use --rpc-url only for view queries. |
Typed arguments
Positional ARGS are marshalled per [functions.<fn>].inputs in declaration order. Per type:
- Primitives (
u8..u128,i8..i128,bool,address,hash32,bytes,string) — bare values.address-typed inputs accept either a0x-prefixed 64-char hex literal OR a wallet name from the local keystore (devnet-0,alice, …) — wallet names resolve to the keystore entry's address. vec(T)— JSON array literal:'[1,2,3]'(standard borshVec<T>wire shape).- Struct from
[types.<Name>]— JSON5 object literal:'{maker:devnet-0,amount:100,paid:false}'. Field order does not matter; the marshaller looks fields up by name. - Enum from
[types.<Name>]— variant name as a bare string:Pending. v1 enums are unit-only. - Unquoted
0xhex literals of 16+ chars are auto-quoted before JSON5 parse, so 32-byte hash + address values don't need surrounding quotes inside struct + array literals.
# View mode (free, no tx, no gas, no signing):
otigen call my-counter get
otigen call my-token balance_of devnet-0 # wallet-name address
otigen call my-token balance_of 0x9b8c... # explicit hex address
otigen call my-pool echo_amounts '[100,200,300]'
# Struct + enum
otigen call my-orders create '{maker:devnet-0,amount:100,paid:false}'
otigen call my-orders set_status Active
# Raw hex view return (default decodes)
otigen call my-token balance_of devnet-0 --raw # 0x40420f00000000000000000000000000
# Mutating mode (signed tx):
otigen call my-counter increment --from devnet-0 --password-stdin <<< pw
otigen call my-token transfer 0x9b8c... 1000 --from devnet-0 --password-stdin <<< pw
otigen call my-token transfer devnet-1 1000 --from devnet-0 --password-stdin <<< pw
# Escape hatch — pre-encoded calldata when no local otigen.toml is available
otigen call my-contract some_fn --args 0x0100000000000000
Auto-decoded view returns
By default, view-call returns are decoded per [functions.<fn>].outputs:
- Single output → bare value (
1000000for auint128). - Multi-output → tuple syntax (
(true, 1000000)). - Compound shapes (
vec(T),struct(<Name>), enum) → JSON5-style.
--raw preserves the on-wire hex — useful for piping into external decoders or for contracts the CLI doesn't have an outputs schema for. In --json mode the call_result event carries return_data (raw hex) alongside a separate decoded field with the decoded form (see crates/otigen-cli/src/commands/call.rs for the exact event shape).
Mutating calls decode too
A write function that returns data (pyde::return) shows it the same way — the payload rides the tx receipt's return_data and is decoded per the declared outputs:
✓ Call succeeded.
Return: 2
In --json mode the call_included event carries the return_data hex. The console's tx command prints the raw hex.
Reverts show the author's message
A revert — view or tx — prints the string the contract passed to pyde::revert, never a wasm backtrace:
otigen [ERROR] Reverted: erc721: nonexistent token
This works across call, deploy (constructor reverts included), send, and the console.
otigen send
Native PYDE transfer between accounts. The simplest tx the chain processes — TxType::Standard against a recipient with no data, the cheapest gas budget the engine accepts (MIN_GAS_LIMIT = 21000).
otigen send [OPTIONS] <RECIPIENT> <AMOUNT>
| Arg / Flag | Default | What it does |
|---|---|---|
<RECIPIENT> | required | 0x-prefixed 32-byte address OR a wallet name from ~/.pyde/keystore.json (e.g. devnet-1). Omitted ⇒ prompt on a TTY. |
<AMOUNT> | required | Amount in quanta (1 PYDE = 10⁹ quanta). Decimal with _ separators (100_000_000) or 0x-hex. Omitted ⇒ prompt on a TTY. |
--from <WALLET> | [wallet.default_account] | Sender wallet account name. |
--no-wait | off | Skip the receipt poll — return immediately after submission with the tx hash. |
--password-stdin | off | Read wallet password from stdin. |
--rpc-url <URL> | from otigen.toml | One-shot RPC URL override. REQUIRES --chain-id. |
--chain-id <N> | from otigen.toml | Required when --rpc-url is set; the chain's tx-hash domain. |
--gas-limit <N> | MIN_GAS_LIMIT (21000) | Override the default gas budget. Rarely needed for an EOA-to-EOA transfer; raise it when the recipient is a contract that performs work in its receive hook. |
otigen send devnet-1 100_000_000 --from devnet-0 --password-stdin <<< pw
otigen send 0xabc... 0x5f5e100 --from devnet-0 --password-stdin <<< pw
otigen send devnet-1 100 --from devnet-0 \
--rpc-url http://127.0.0.1:29933 --chain-id 31337 \
--password-stdin <<< pw
otigen inspect
Read contract / account metadata + storage (spec §3.6).
otigen inspect [OPTIONS] <TARGET>
| Flag | Default | What it does |
|---|---|---|
<TARGET> | required | Contract name or 0x-prefixed address. At a workspace root, a member's [contract].name resolves that member's manifest + the workspace network. |
--state-field <NAME> | none | Substrate-typed storage read. Slot = Poseidon2(self_address ‖ field_name); decoded per the [state].schema type token. Use this for any contract built with #[pyde::declare_storage]. |
--field <NAME> | none | Legacy pre-substrate raw-slot read. Slot = Poseidon2(name.as_bytes()). Mutually exclusive with --state-field. |
--at-wave <N> | none | Read state as of a specific wave. Honored only by archive nodes. |
--rpc-url <URL> | from otigen.toml | One-shot RPC override. Skips otigen.toml network resolution entirely. |
Default mode prints the account snapshot: address, account type, balance, nonce, code hash, code size, state root. --state-field / --field short-circuit to a single-slot read.
otigen inspect 0xabc... # full account snapshot
otigen inspect 0xabc... --state-field counter # substrate field
otigen inspect 0xabc... --field counter # legacy raw-slot field
otigen inspect my-token --rpc-url https://rpc.example
otigen verify
Verify that a deployed contract's bytes match a local bundle (spec §3.9).
otigen verify [OPTIONS] <TARGET>
| Flag | Default | What it does |
|---|---|---|
<TARGET> | required | Contract address or name. At a workspace root, a member's [contract].name resolves that member's bundle + the workspace network. |
--bundle <PATH> | <config-dir>/artifacts/<target>.bundle/ (name TARGET) or <config-dir>/artifacts/<contract.name>.bundle/ (hex TARGET) | Local bundle to compare against. |
--strict-toolchain | off | Also compare the toolchain version pin in manifest.json against the running rustc / TinyGo / asc / clang. Mismatch fails verify even when bytes match. |
--explorer <URL> | none | Submit the bundle to an external verifying explorer. Posts (contract.wasm, manifest.json, metadata.json) to <URL>/api/v1/contracts/<addr>/verify. |
--api-key-env <VAR> | EXPLORER_API_KEY | Read the explorer API key from an env var (bearer token). Default EXPLORER_API_KEY — set the env var and you can omit the flag. |
--api-key-stdin | off | Read the explorer API key from stdin. |
otigen verify 0xabc...
otigen verify 0xabc... --bundle ./snapshot/my-token.bundle
otigen verify 0xabc... --explorer https://explorer.pyde.network --api-key-env PYDE_EXPLORER_KEY
otigen verify 0xabc... --strict-toolchain
otigen devnet
Run a local devnet. The chain runtime is embedded in the otigen binary — no separate pyde download. Single validator, deterministic genesis pre-fund, Ctrl-C for graceful shutdown.
otigen devnet [OPTIONS]
| Flag | Default | What it does |
|---|---|---|
--rpc-listen <ADDR> | none (banner-only) | JSON-RPC server bind address. Pass 127.0.0.1:9933 to enable RPC so deploy / call / console have a target. |
--prefund-count <N> | 10 | Number of pre-funded accounts (devnet-0..devnet-N-1). |
--prefund-amount <QUANTA> | engine default | Per-account genesis balance. |
--chain-id <ID> | 31337 | Chain id this devnet signs against. |
--tick-ms <MS> | 1000 | Idle-wave tick interval. Empty waves still commit every --tick-ms so wave_id advances. |
--fork <FILE_OR_URL> | none | Bootstrap state from an existing chain snapshot. Local borsh file or HTTP(S) URL pointing at pyde_getSnapshot. Mutually exclusive with --prefund-*. (See Lifecycle — there is a known state-root-mismatch issue forking a live devnet; the file path is the more reliable mode today.) |
--no-auto-import-wallets | off | Skip the startup auto-import of devnet-0..devnet-9 into the local keystore (password "devnet"). Default is to import — the anvil-style one-command UX. Set this when running against a pre-populated keystore or in CI. |
On startup, devnet auto-imports devnet-0..devnet-9 into the local keystore with password "devnet" so otigen deploy --from devnet-0 works without a separate wallet import --from-devnet step. Pass --no-auto-import-wallets to skip this when running against a populated keystore or in CI.
otigen devnet --rpc-listen 127.0.0.1:9933
otigen devnet --rpc-listen 127.0.0.1:9933 --tick-ms 500
otigen devnet --fork ./snapshot.borsh --rpc-listen 127.0.0.1:9934
Validator + full-node roles still ship via the engine's own pyde binary — operator concerns, not author concerns.
otigen console
Interactive REPL against a Pyde node (spec §3.8).
otigen console [OPTIONS]
| Flag | Default | What it does |
|---|---|---|
--from <WALLET> | [wallet.default_account] | Account name for tx commands. Views work without a sender bound. |
--password-stdin | off | Read wallet password from stdin (cached for the session after first tx). |
Drops into a pyde> prompt with line editing and persistent history. The MVP surface today:
| Command | What it does |
|---|---|
help | Show the catalog. |
balance <ADDR> | PYDE balance. |
nonce <ADDR> | Next nonce. |
call <ADDR> <FN> [HEX] | View call (free, no tx). |
tx <ADDR> <FN> [HEX] [--value <DEC>] | Sign + submit + receipt poll. Wallet unlocked once, cached. |
state <ADDR> <FIELD> | Substrate-typed scalar storage read. |
events <ADDR> [--from N] [--to N] [--limit N] | Pull pyde_getLogs with optional wave bounds. |
subscribe <ADDR> | WebSocket subscription to live events emitted by a contract (blocks until Ctrl-C). |
exit / quit | Leave. |
Addresses accept either 0x... hex or registered names (resolved via pyde_resolveName).
otigen console --network devnet --from devnet-0
otigen validator
Read-only queries over the chain-side validator registry.
otigen validator <ACTION> [OPTIONS]
| Subaction | Usage | What it returns |
|---|---|---|
show <ADDR> | otigen validator show 0x... | One validator's full record: operator, pubkey, stake, status, jail/unbond timeline, last-claimed rps, uptime. |
by-operator <ADDR> | otigen validator by-operator 0x... | Every validator an operator runs. |
Exits non-zero with NotAValidator for unregistered addresses so scripts can branch on exit code without parsing stdout.
otigen validator show 0xabc...
otigen validator by-operator 0xdef...
Registration / stake / unbond / unjail flows live on the engine's pyde binary — those are tx submission, not introspection.
otigen update
Pull the latest release and replace the binary. Wraps the canonical curl install one-liner so you don't have to copy the URL each time.
otigen update [OPTIONS]
| Flag | Default | What it does |
|---|---|---|
--version <V> | latest | Install a specific tag instead of the freshest one. Accepts either v0.1.0-alpha.5 or the mirror's full otigen-v0.1.0-alpha.5. |
--check | off | Print the latest version + the currently installed one and exit. No filesystem side-effect. Exit code 0 = up-to-date, non-zero = drift. Handy for pre-commit hooks. |
--no-verify-sig | off | Skip the install script's sigstore verification. Use only on locked-down machines that can't install cosign. |
otigen update # latest
otigen update --version v0.1.0-alpha.3 # pin a tag
otigen update --check # poll without side effect
Under the hood: curl -fsSL <install-url> | bash -s -- <flags>. Same URL the installation page documents; otigen update is just sugar so the flow stays inside the CLI.
Your First Contract
End-to-end: scaffold → write → test. By the end you'll have a working contract that passes a behaviour suite, with execution traces visible on demand.
This chapter uses Rust. For TinyGo / AssemblyScript / C, the patterns are identical; the per-language README.md in each scaffolded project carries the syntactic equivalent. otigen new --lang <go|as|c> produces the other-language scaffolds — it falls through to the minimal counter starter when no Rust-only template is requested.
1. Scaffold
otigen new my-counter --lang rust --from counter
cd my-counter
✓ Scaffolded my-counter — Rust contract from `counter` (7 files)
my-counter/
├─ src/lib.rs the contract (start here)
├─ otigen.toml state schema · functions · networks
└─ tests/contract.test.toml behaviour tests
Next steps:
cd my-counter
otigen test # compile + run the behaviour suite
otigen deploy # against `otigen devnet`
otigen test invokes the per-language compiler by default; --no-compile opts out when a CI step has already produced the wasm.
What landed:
my-counter/
├── Cargo.toml # cdylib + release profile tuned for WASM size
├── Makefile # build / test / deploy / inspect / verify shortcuts
├── README.md # project-local Pyde cheatsheet
├── otigen.toml # contract metadata + state schema + network
├── .gitignore
├── src/
│ └── lib.rs # your contract (start here)
└── tests/
└── contract.test.toml # behaviour tests
Seven files. The Rust scaffold pulls host fns + the entry macro from the pyde-host family of crates on crates.io, referenced from Cargo.toml (no src/host_fns.rs in the project tree — the macro substrate is the canonical interface). You'll spend your time in:
src/lib.rs— your contract code.otigen.toml— declares state fields + function signatures + network endpoints.tests/contract.test.toml— behaviour assertions.
2. The default contract
otigen new --from counter produces a minimal counter — one uint64 storage slot, two entry points:
#![allow(unused)] fn main() { // src/lib.rs (excerpt — the file ships with header docs not reproduced here) #![no_std] extern crate alloc; use core::panic::PanicInfo; use pyde_host as pyde; #[panic_handler] fn panic(_info: &PanicInfo) -> ! { core::arch::wasm32::unreachable() } // Reads `otigen.toml`'s `[state] schema` at compile time and emits // one typed accessor per field. `storage::counter()` returns a // `CounterField` with `.read() -> u64`, `.write(value: u64)`, // `.delete()`. Misspelling a field name or supplying the wrong type // is a compile error. pyde::declare_storage!(); #[pyde::entry] fn increment() -> u64 { let next = storage::counter().read().wrapping_add(1); storage::counter().write(next); next } #[pyde::entry] fn get() -> u64 { storage::counter().read() } }
The #[pyde::entry] macro wraps each function in the spec-mandated () -> () WASM shim (HOST_FN_ABI_SPEC §3.5.2) — it decodes calldata from pyde::calldata_*, calls the inner body, and surfaces the return value via pyde::return_ (the trailing underscore avoids the Rust keyword). You write idiomatic Rust; the macro handles the chain-side ABI.
The corresponding otigen.toml:
[state]
schema = [
{ name = "counter", type = "uint64" },
]
[functions.increment]
attributes = ["entry"] # callable from outside the contract
inputs = []
outputs = ["uint64"]
[functions.get]
attributes = ["entry", "view"] # callable + must not mutate (engine enforces)
inputs = []
outputs = ["uint64"]
For the meaning of attributes values (entry, view, payable, constructor, fallback, receive), see HOST_FN_ABI_SPEC §3.5. Mapping fields use type = "map" with keys = [...], value = "...", or the Solidity-style sugar type = "mapping(K => V)" (lowered to the canonical form at build time; up to 3 keys including nested mapping(K => mapping(K2 => V))).
3. Add a function
Let's add a decrement that reverts when the counter is at zero. This exercises three things:
- Reading + writing storage in the same call.
- Reverting from inside the contract.
- Asserting the revert path in a test.
Append to src/lib.rs:
#![allow(unused)] fn main() { #[pyde::entry] fn decrement() -> u64 { let current = storage::counter().read(); if current == 0 { // pyde::revert never returns; the engine rolls back every // state change since this call started. pyde::revert("counter: at zero"); } let next = current - 1; storage::counter().write(next); next } }
Declare it in otigen.toml:
[functions.decrement]
attributes = ["entry"]
inputs = []
outputs = ["uint64"]
4. Add tests
Edit tests/contract.test.toml — append three new [[tests]] entries:
# decrement from zero reverts with "counter: at zero".
[[tests]]
name = "decrement_at_zero_reverts"
[[tests.calls]]
function = "decrement"
expect.revert = "counter: at zero"
# decrement from non-zero succeeds and reaches zero.
[[tests]]
name = "decrement_to_zero"
[[tests.calls]]
function = "increment"
[[tests.calls]]
function = "increment"
[[tests.calls]]
function = "decrement"
[[tests.calls]]
function = "decrement"
expect.return_value = "0"
[tests.expect]
storage.counter = "0"
# decrement after revert leaves state untouched (rollback semantics).
[[tests]]
name = "revert_rolls_back_state"
[[tests.calls]]
function = "increment"
[[tests.calls]]
function = "decrement"
expect.return_value = "0"
# This call reverts. State changes since the call started are
# discarded; the counter stays at the value from the previous call.
[[tests.calls]]
function = "decrement"
expect.revert = "counter: at zero"
[[tests.calls]]
function = "get"
expect.return_value = "0"
5. Run the tests
otigen test
→ Compiling (rust) — cargo build --target wasm32-unknown-unknown --release
Finished `release` profile [optimized] target(s) in 11.28s
✓ Compiled → ./target/wasm32-unknown-unknown/release/my_counter.wasm
Running 6 tests in ./tests/contract.test.toml (via engine)
✓ get_returns_zero_initially (29.55 ms)
✓ increment_advances_by_one (7.72 ms)
✓ three_increments_yield_three (6.82 ms)
✓ decrement_at_zero_reverts (8.04 ms)
✓ decrement_to_zero (8.91 ms)
✓ revert_rolls_back_state (9.13 ms)
test result: ok. 6 passed; 0 failed; 0 skipped (6 ran)
(Cargo converts the kebab-case project name to snake_case for the wasm filename — that's why my-counter produces my_counter.wasm.)
(If you get a different result, jump to Debugging — the most common cause is forgetting cargo build --release, but otigen test invokes that for you by default.)
The runner executes against pyde-engine-wasm-exec::WasmExecutor — the same code path mainnet uses. That's the (via engine) marker in the output line. The legacy in-process mock is still available via --no-engine for the handful of cases the engine can't yet host (today: parachains).
6. Raise the verbosity
otigen test accepts the standard clap -v flag, repeated for more detail:
otigen test # default — per-test pass/fail + duration
otigen test -v # + per-test gas used + INFO logs from the runner
otigen test -vv # + emitted event list (topic0 + sizes)
otigen test -vvv # + per-call traces (fn args / return / gas)
otigen test -vvvv # + storage diffs (slot → before / after)
otigen test --json # NDJSON event stream for CI / scripted consumers
To see successful calls' return values + gas, use otigen test -v — -v adds gas to the pass line; -vvv adds per-call traces (fn args + return + gas).
otigen test --dry-run
--dry-run parses + resolves every test without executing the WASM — useful for verifying that your storage.<field> assertions resolve to the same Poseidon2-derived slot the contract writes to.
7. Lock in a gas budget
Once a test is green and the gas number looks reasonable, freeze it as a regression guard:
[[tests.calls]]
function = "increment"
expect.return_value = "1"
expect.gas_max = "250000" # fail if increment grows past the current budget — pick a number your green run is comfortably under
expect.gas_max is an upper-bound check — your contract can use any value ≤ the budget. Prefer gas_max over expect.gas (exact match) — exact is brittle to opcode-level codegen changes.
8. Next: custom types & complex arguments
Real contracts pass more than primitives across the ABI. Two common needs — structs and enums — are declared in a [types.<Name>] block in otigen.toml, then referenced by bare name in function signatures.
Here's a minimal Order struct + Status enum threaded through a create_order entry point.
Declare the types in otigen.toml
[types.Order]
fields = [
{ name = "id", type = "uint64" },
{ name = "maker", type = "address" },
{ name = "amount", type = "uint128" },
{ name = "paid", type = "bool" },
]
[types.Status]
variants = [
{ name = "Pending" },
{ name = "Active" },
{ name = "Cancelled" },
]
[functions.create_order]
attributes = ["entry", "view"] # body has no storage writes — declarable view
inputs = ["Order"]
outputs = ["Status"]
Custom types are referenced by bare name in function inputs / outputs ("Order", "Status") and via the struct(<Name>) wrapper in [state].schema (e.g. type = "struct(Order)"). v1 enums are unit-only — no data-carrying variants. See OTIGEN_BINARY_SPEC §4.13 for the full schema.
Declare the matching Rust types
Every custom type needs #[derive(BorshSerialize, BorshDeserialize)] — the macro substrate calls borsh on these types when decoding #[pyde::entry] arguments and round-tripping storage:
#![allow(unused)] fn main() { use borsh::{BorshSerialize, BorshDeserialize}; use pyde_host as pyde; #[derive(BorshSerialize, BorshDeserialize)] pub struct Order { pub id: u64, pub maker: pyde::Address, pub amount: u128, pub paid: bool, } #[derive(BorshSerialize, BorshDeserialize)] pub enum Status { Pending, Active, Cancelled, } #[pyde::entry] fn create_order(order: Order) -> Status { // store, validate, emit — whatever the contract needs. // The macro decoded `order` from calldata for you. Status::Pending } }
Call it with typed args
JSON5 object literal for the struct, variant name for the enum return decode:
otigen call <addr> create_order '{id:1,maker:devnet-0,amount:100,paid:false}'
# excerpt — header lines (Target / RPC / Calldata) omitted for brevity
Call create_order on 0xe37844… (devnet)
Mode: view (pyde_call — no tx, no gas, no nonce)
✓ Call succeeded.
Return: Pending
Field order in the literal doesn't matter — '{amount:100,maker:devnet-0,id:1,paid:false}' works equivalently. Address-typed fields accept wallet names from the keystore OR 0x-prefixed hex. Unquoted 0x hex literals 16+ chars long don't need surrounding quotes inside the JSON5.
What just happened
You scaffolded a project, added a function, wrote tests for both the success and failure paths, and saw the contract execute end-to-end through the production WASM executor.
The next chapter — Shipping — covers the build pipeline and the deploy flow. Then Inspect & Verify shows how to read state from a deployed contract.
For the deeper why (host fn ABI, slot derivation, WASM constraints), see WASM Contract Author Guide.
Shipping
Building a deploy bundle, configuring a wallet, picking a network, then submitting the contract.
By the end of this chapter, your counter contract is live on devnet and you've held the transaction receipt.
1. otigen build
otigen build
→ Compiling (rust) — cargo build --target wasm32-unknown-unknown --release
Finished `release` profile [optimized] target(s) in 0.27s
✓ Compiled → ./target/wasm32-unknown-unknown/release/my_counter.wasm
✓ Built "my-counter" → ./artifacts/my-counter.bundle
wasm: 5077 bytes (blake3 4ac6059a67dea1d8)
abi: 147 bytes (blake3 e8cc4b94b095fecc)
otigen build runs two steps:
- Language compile.
cargo build --target wasm32-unknown-unknown --releasefor Rust, ortinygo build/asc/clangfor the other languages. Produces a.wasmat the path declared inotigen.toml's[contract.lang].output. - Validate + bundle. Reads the
.wasm, validates it against the host-fn allowlist + the[functions.*]export consistency rules, embeds apyde.abicustom section derived from the manifest, and writes everything intoartifacts/<name>.bundle/.
--no-compile skips step 1 and packages whatever's already on disk.
Other flags: --release / --debug toggle the debug-vs-release expectation; --out <path> overrides the default <config_dir>/artifacts/ so monorepo workflows write the bundle next to the project; --no-strict is the escape hatch for bundling test-only host fns (pyde::debug_log) for local inspection — never use it for a bundle that reaches a network.
What's in the bundle
artifacts/my-counter.bundle/
├── contract.wasm # the WASM with the pyde.abi section injected
├── otigen.toml # snapshot of the project manifest
├── abi.json # decoded ABI (functions + events + state schema)
├── metadata.json # JSON-extracted [metadata] section for explorers/wallets
└── manifest.json # build provenance (otigen version, language toolchain pin, timestamp, hashes, metadata_hash_blake3)
The bundle is the only thing the chain ever sees. Source, tests, Cargo.lock stay local.
What gets validated
| Check | What fails |
|---|---|
| Well-formed WASM | wasmparser rejects malformed bytes. |
| Import allowlist | Any import outside pyde::* ⇒ rejected. |
| Function allowlist | Imports of pyde::* fns not in HOST_FN_ABI_SPEC §7 ⇒ rejected. |
| Parachain gating | A non-parachain contract importing §8 fns ⇒ rejected. |
| Export consistency | Every [functions.X] in otigen.toml must be exported by the WASM. Every non-underscored export must be declared. |
| Signature consistency | Declared [functions.X] inputs / outputs must match the code's REAL signature, recorded by the #[pyde::entry] macro in a pyde.sig.v1 section (pyde-host ≥ 0.1.0-alpha.7). Mismatch fails the build with a declared-vs-actual diagnostic. Contracts without the section (older pyde-host, AS / TinyGo / C manual FFI) skip the check. The section is stripped from the bundle — the chain never sees it. |
| Entry shape | Every entry must export () -> () (HOST_FN_ABI_SPEC §3.5.2). The #[pyde::entry] macro generates this shim; hand-rolled #[no_mangle] pub extern "C" fn foo(args, ...) -> ret is rejected. |
| Forbidden features | Threads, SIMD, GC, multi-memory, memory64, component model, exceptions, tail-call, custom-page-sizes ⇒ rejected (deterministic-execution subset only). Reference types and bulk-memory ARE accepted (LLVM emits them unconditionally). |
A clean otigen build = a deployable bundle. If validation fails, the error message points at the exact violation; fix the source + re-run. The process exit code is VALIDATION_FAILURE (1) — scripts can rely on it.
Reproducibility
otigen build is deterministic modulo a build_timestamp field. Two clean rebuilds of the same source + the same toolchain pin produce bundles that hash byte-identical (apart from that timestamp). That's the property otigen verify (next chapter) relies on. Auditors re-build from source on a clean machine, then otigen verify <addr> --strict-toolchain against the deployed contract.
2. Wallets
Pyde signs transactions with FALCON-512 (post-quantum, per HOST_FN_ABI_SPEC §7.7). Keys are managed via otigen wallet.
Create
otigen wallet new deployer
New password: ************
Confirm password: ************
✓ Wallet created: deployer
Address: 0x9b8c7d6e5f4a3b2c...
Keystore: ~/.pyde/keystore.json
[NAME] is positional. Under --json mode or piped stdin, supply it on the command line (interactive prompts disabled). Use --password-stdin to pipe the password (one line):
printf 'pw' | otigen wallet new alice --password-stdin
The keystore is a single file at ~/.pyde/keystore.json. Argon2id-derived keys encrypt each account's secret with AES-256-GCM. Multiple accounts live in one file; passwords are per-account.
Other wallet commands
otigen wallet list # list every account
otigen wallet show <NAME> # print address + pubkey
otigen wallet delete <NAME> # remove (asks confirmation)
otigen wallet password <NAME> # rotate the password (TTY only)
otigen wallet import <NAME> --from-file <PATH> # restore a backup
otigen wallet import --from-devnet # bulk-import the 10 prefunded devnet accounts (override count with --count <N>, prefix with --prefix <str>)
otigen wallet export <NAME> --out <PATH> # write a portable encrypted backup
otigen wallet sign <NAME> --message <MSG> # off-chain FALCON sig (NOT for chain txs)
otigen wallet verify [NAME] --message <MSG> --signature <HEX>
Full reference: commands.md and OTIGEN_BINARY_SPEC §3.7.
Funding
A fresh account has zero balance. On devnet you don't have to do anything — otigen devnet auto-imports devnet-0..devnet-9 into ~/.pyde/keystore.json at startup (password "devnet"). If you skipped that with --no-auto-import-wallets, or you want to re-import into a fresh keystore, run:
otigen devnet --rpc-listen 127.0.0.1:9933 & # in another terminal
otigen wallet import --from-devnet # imports devnet-0..devnet-9
✓ Imported devnet accounts → ~/.pyde/keystore.json
• devnet-0 0x9b8c7d6e5f4a3b2c…
• devnet-1 0x…
…
• devnet-9 0x…
Use any of them via: otigen <cmd> --from devnet-0
These wallets sign valid txs against any running `pyde devnet`
(their balance is set by the devnet's genesis prefund — 10,000,000 PYDE each).
The accounts are derived via Blake3("pyde-devnet-v1/" || i) and re-derive identically across machines. Their secrets are public by design — they're for tests, not for anything that matters.
To move PYDE between accounts (e.g. fund a fresh deployer from devnet-0), use otigen send <recipient> <amount-in-quanta> --from devnet-0. Recipient accepts either a 0x-prefixed 32-byte address or a wallet name; amount is decimal quanta (1_000_000_000 = 1 PYDE) or 0x-hex.
For real funding (testnet / mainnet), real PYDE is required. There is no POST /faucet HTTP endpoint on the devnet RPC; the prefund-at-genesis path above is the only auto-funding the binary provides today. A testnet faucet UI is planned but not yet live.
3. Networks
otigen.toml declares one or more networks:
[network.default]
name = "devnet"
[network.devnet]
rpc_url = "http://127.0.0.1:9933"
chain_id = 31337
[network.testnet]
rpc_url = "https://rpc.testnet.pyde.network"
chain_id = 2
[network.mainnet]
rpc_url = "https://rpc.pyde.network"
chain_id = 1
(Per OTIGEN_BINARY_SPEC §6.1, chain_id = 2 is the testnet sentinel; chain_id = 31337 is the canonical devnet "don't replay" sentinel.)
[network.default] picks which is used when no --network flag is passed. You can declare arbitrarily many; the --network <name> flag overrides per-command.
One-shot RPC override
For ad-hoc invocations against an alt port — e.g. a CI worker spinning a devnet on 127.0.0.1:29933 because 9933 is taken by a multi-validator cluster — deploy / upgrade / pause / unpause / kill / send all accept --rpc-url + --chain-id (write-tx commands need both for signature replay protection). Read-only ops — inspect, call, verify — accept --rpc-url alone.
otigen deploy --from devnet-0 --password-stdin \
--rpc-url http://127.0.0.1:29933 \
--chain-id 31337 \
<<< pw
--rpc-url requires --chain-id (signed-tx replay protection). Passing --rpc-url without --chain-id returns InvalidArgs with exit 1; --chain-id without --rpc-url is silently ignored.
4. otigen deploy
otigen deploy --from devnet-0 --password-stdin <<< pw
Deploying "my-counter" (Contract) to devnet
Bundle: artifacts/my-counter.bundle
RPC: http://127.0.0.1:9933
Account: devnet-0 (chain 31337)
Nonce: 0
Gas: 10,000,000 quanta (limit)
Tx hash: 0x6400519b791aa353488443b66b98c37b2f8bb1aa148fed313c013fe6b5bf62dd
Wire: 6037 bytes
Submitted. Server tx hash: 0x6400519b791aa353488443b66b98c37b2f8bb1aa148fed313c013fe6b5bf62dd
Waiting for inclusion (timeout 60s)...
Contract: 0x5224c65fbc03fc63ab4cc6c30906e593342edd42b540f489d6b279dbc689f413 (registered as "my-counter")
✓ Deployed. Try either form:
otigen call my-counter <fn> # by name
otigen call 0x5224c65fbc03fc63ab4cc6c30906e593342edd42b540f489d6b279dbc689f413 <fn> # by hex address
What happened, step by step (per OTIGEN_BINARY_SPEC §3.3):
- Bundle re-validation.
otigenre-runs every validator fromotigen buildagainst the bundle. Catches a hand-edited bundle. - Network resolution. Selects the network. Reads
rpc_url+chain_idfrom[network.<X>], or from--rpc-url+--chain-idif supplied. - Wallet unlock. Prompts for the deployer password (or reads stdin under
--password-stdin). - Nonce fetch. Queries
pyde_getTransactionCountfor the deployer's next nonce. - Canonical tx hash. Computes the sig-excluded Poseidon2 tx hash the chain verifier reproduces.
- FALCON-512 sign. Produces a ~666-byte signature.
- Submit. POSTs to
pyde_sendRawTransaction. Receipt poll timeout is 60 seconds, constant (not CLI-configurable). - Print contract address. Surfaced from the receipt.
Gas values come from [deploy] in otigen.toml:
[deploy]
gas_limit = 10_000_000
gas_price = "auto" # base_fee + 10% headroom at submission time
There is no --gas-limit / --gas-price CLI flag — change the manifest instead.
Typed constructor args + --value
Typed constructor args follow the wallet flags: otigen deploy --from devnet-0 --password-stdin <<< pw devnet-1 100 invokes the contract's constructor (the [functions.*] entry tagged constructor — any name) with (devnet-1, 100) — addresses resolve through the keystore, numbers accept decimal/hex/underscores. For vec/struct args fall back to --args 0x<hex>. Native PYDE transfer at deploy time: --value <quanta> (1 PYDE = 10⁹ quanta); the constructor sees it via pyde::ctx::value().
--dry-run
otigen deploy --dry-run --from deployer
Goes through steps 1–6, prints the would-be tx, exits without submitting. Useful for inspecting wire bytes before pulling the trigger.
--no-wait
otigen deploy --no-wait --from deployer
Submits without polling for the receipt. Returns immediately with the server tx hash. Useful for scripts that want fire-and-forget; query pyde_getTransactionReceipt later.
Contract addresses
The deployed address is Poseidon2(self_namespace ‖ contract.name) — derived deterministically from the contract's registered name. Two consequences:
- You can compute the address before deploy (e.g. for hard-coding into dependent contracts).
- Two contracts can't share a name on the same chain. The chain's name registry rejects duplicate names at deploy time; the failure surfaces as an RPC error from
pyde_sendRawTransaction.
For parachains, the namespace differs (pyde-parachain: vs pyde-contract:); they share no name with the contract registry.
5. After deploy
The contract exists on-chain. The next chapter — Inspect & Verify — shows you how to read its state, call its functions off-chain (free, via RPC), and prove the on-chain bytes match what you built locally.
Multi-Contract Workspaces
Most real projects are more than one contract: a token plus a vault, a registry plus the things it registers, a router plus its pools. A workspace groups several contracts that build, test, and deploy together — and lets one contract's constructor take another's deployed address without you copy-pasting hex.
otigen init scaffolds a workspace. otigen new adds a contract to it.
Every command you already know — build, test, deploy, inspect,
verify, call — works across the whole workspace or, with
--contract <name>, against a single member.
One contract per crate. Each member is a self-contained project with
its own otigen.toml, source, and tests — a Rust member is its own
crate. The workspace is the coordination layer; it doesn't merge the
members into one binary.
For the single-contract flow this chapter builds on — writing a contract, the bundle internals, receipts — see Your First Contract and Shipping Contracts.
1. Scaffold a workspace
otigen init shop --lang rust
cd shop
✓ Scaffolded shop — Rust workspace (starter member: contracts/counter/)
Next steps:
cd shop
otigen new <name> # add another contract
otigen test # build + test every member
otigen deploy # against `otigen devnet`
What landed:
shop/
├── otigen.toml # the workspace manifest (members, order, args)
├── .gitignore # ignores artifacts/, per-member build output
├── README.md # workspace cheatsheet
├── Makefile # build / test / deploy / clean
└── contracts/
└── counter/ # the starter member — a full single-contract project
├── Cargo.toml
├── otigen.toml
├── src/lib.rs
└── tests/contract.test.toml
The root otigen.toml carries a [workspace] table; the member under
contracts/counter/ is an ordinary single-contract project. The starter
is named counter (its [contract].name) — rename it before a real
deploy, because on-chain names are globally unique.
--lang picks the language for the starter member; TinyGo,
AssemblyScript, and C scaffold the same counter starter in that
language.
2. Add a contract
Run otigen new <name> from the workspace root:
otigen new vault --from counter --lang rust
✓ Added vault to the workspace — Rust contract from `counter`
contracts/vault/
├─ src/lib.rs the contract (start here)
├─ otigen.toml state schema · functions · networks
└─ tests/contract.test.toml behaviour tests
Next steps:
otigen test --contract vault # test just this member
otigen deploy # deploy every member in order
This scaffolds contracts/vault/ and registers it in the root manifest —
appending contracts/vault to [workspace].members and vault to
[workspace].order, preserving your formatting and comments. (otigen new run outside a workspace still scaffolds a standalone
single-contract project, exactly as before.)
3. The workspace manifest
# shop — Pyde workspace manifest.
[workspace]
# Member contract directories (relative to this file). `otigen new`
# appends here automatically.
members = ["contracts/counter", "contracts/vault"]
# Deploy sequence, by member CONTRACT name (the [contract].name inside
# each member's manifest — not the directory). A contract must appear
# after every contract it references via @name.
order = ["counter", "vault"]
# Per-member constructor arguments. A "@name" string resolves to that
# member's deployed address at deploy time; wallet names and hex
# addresses are plain strings.
[workspace.args]
vault = ["@counter", "devnet-0"]
[network.default]
name = "devnet"
[network.devnet]
rpc_url = "http://127.0.0.1:9933"
chain_id = 31337
[deploy]
gas_limit = 10_000_000
gas_price = "auto"
Three things to know:
membersare directory paths;orderand[workspace.args]key off the member's[contract].name. They differ if you rename a contract without moving its directory.orderis the deploy sequence. When it's set, it must list every member — otherwise a member would be silently skipped. A contract must come after everything it references via@name.[workspace.args]are constructor arguments, one array per member. A@nameentry resolves to that member's deployed address; everything else (wallet names,0x…addresses, numbers, booleans) is passed through to the member's declared constructor inputs (the[functions.*]entry taggedconstructor).
The workspace [network.*] tables are authoritative — every member
deploys, and every workspace-level call / inspect / verify
resolves, against these, not against a member's own network table.
4. Build & test the whole workspace
otigen build # build every member → artifacts/<name>.bundle/
otigen test # build + test every member
otigen build compiles and bundles each member into the shared
artifacts/ directory at the workspace root, and prunes bundles for
members you've removed. otigen test mirrors it: it builds, then runs
each member's tests/*.test.toml, with a workspace summary.
── counter ─────────────────────────────────────────────
test result: ok. 3 passed; 0 failed; 0 skipped (3 ran)
── vault ─────────────────────────────────────────────
test result: ok. 3 passed; 0 failed; 0 skipped (3 ran)
✓ workspace test: 2 contract(s) passed
A member with no test file is skipped (⊘ <name> (no tests)), not
failed. Scope either command to one member with --contract <name>;
--watch isn't supported at the workspace level (cd into a member to
watch it).
5. Deploy
otigen deploy --from devnet-0
The deployer account comes from --from, or from [wallet] default_account in the workspace manifest if you set one. otigen deploy at a workspace root does the whole thing in one command:
- Builds every member first (compile + bundle), so a deploy always
uses fresh artifacts — there's no separate "run
otigen buildfirst" step. - Prints the plan, then deploys each member in
[workspace].order, resolving@namecross-references as it goes.
✓ Built 2 contract(s) into ./artifacts
Deploy plan:
Network: devnet (chain 31337)
RPC: http://127.0.0.1:9933
Account: devnet-0
Order: counter → vault
▸ counter (nonce 0)
✓ counter → 0xf92c27a16aa74d5aca7be4d9072836d1fe220c66b7b9cb194b6fac83185370cf
▸ vault (nonce 1) args: [0xf92c27a1…, devnet-0]
✓ vault → 0xd2a03f70120d5fe24f71134dfa9d9835c1d56d32ef68077cf8fa8601f4cef1ee
✓ Deployed 2 contract(s). Addresses cached at ./artifacts/deployments/devnet.json
Notice vault's line shows its resolved args: the @counter in the
manifest has already become counter's real deployed address, so you
see exactly what goes on-chain before it's submitted. The wallet is
unlocked once and the nonce is sequenced locally across all members.
Preview without deploying
--dry-run prints the full plan — network, RPC, account, order, and each
member's resolved args (@refs shown as a zero-address placeholder) —
and submits nothing, builds nothing. It never asks for a wallet password
and doesn't need a running node: the preview is fully offline.
otigen deploy --dry-run --from devnet-0
Deploy plan (dry-run — nothing submitted):
Network: devnet (chain 31337)
RPC: http://127.0.0.1:9933
Account: devnet-0
Order: counter → vault
▸ counter
▸ vault args: [0x0000…0000, devnet-0]
✓ dry-run — 2 contract(s) prepared, none submitted
One member, and re-runs
-
otigen deploy --contract vaultdeploys just that member. -
With
--contract, constructor args can come straight from the command line instead of[workspace.args]— handy for one-off deploys with values you don't want to commit to the manifest:otigen deploy --contract usdc usdc-token USDC 6 100000000000000 --from devnet-0Positional args override that member's
[workspace.args]entry;@namevalues still resolve to member addresses;--args 0x<hex>is the raw-calldata escape hatch, and--value <quanta>funds the constructor. (Without--contract, CLI args are rejected — one arg set can't address several members.) -
Deploy is idempotent: on a re-run, a member that's already registered on-chain (by name) is skipped — so re-running after a partial failure only deploys what's missing. If you passed explicit CLI args and the member is skipped, otigen warns you they had no effect (a registered name can't be deployed twice).
✓ Deployed 0 contract(s), 2 already deployed, skipped.
The deployed addresses are cached at
artifacts/deployments/<network>.json, keyed by network so different
chains never clobber each other's address book.
6. See what's deployed
otigen addresses
Deployments on devnet (2 member(s)):
counter 0xf92c27a16aa74d5aca7be4d9072836d1fe220c66b7b9cb194b6fac83185370cf
vault 0xd2a03f70120d5fe24f71134dfa9d9835c1d56d32ef68077cf8fa8601f4cef1ee
Members that haven't been deployed to the selected network show
(not deployed). --network <name> lists a different network's
deployments; --json emits the raw name → address map for scripts.
7. Call, inspect, verify — by member name
From the workspace root, address a member by its [contract].name.
otigen resolves the member's manifest for the typed-arg schema and the
target address, over the authoritative workspace network:
otigen call vault increment --from devnet-0 # a state-changing call
otigen call vault get # a view read
otigen inspect vault # on-chain account + ABI
otigen verify vault # bundle == deployed bytecode
Mode: view (pyde_call — no tx, no gas, no nonce)
✓ Call succeeded.
Return: 1
inspect and verify accept --rpc-url to target any endpoint
directly, bypassing the manifest — useful for querying a member on a
chain you don't have the project tree for. For the full read surface —
--field, --state-field, byte-diffing a mismatch — see Inspect &
Verify.
When to use a workspace
Reach for a workspace when your contracts are deployed and versioned
together and reference each other. If you're writing a single standalone
contract, otigen new <name> (outside a workspace) still gives you a
plain single-contract project — no workspace overhead. You can always
start single and regroup later.
| Single contract | Workspace | |
|---|---|---|
| Scaffold | otigen new <name> (standalone) | otigen init <name> |
| Root manifest | [contract] | [workspace] |
| Deploy | one bundle | all members in order, @ref-resolved |
| Cross-references | copy addresses by hand | @name in [workspace.args] |
| Target one | (it's the only one) | --contract <name> |
Inspect & Verify
Reading state from a deployed contract. Confirming the on-chain bytes match what you built locally.
1. otigen inspect
Read-only query against a deployed account or contract.
otigen inspect 0xe37844e3800a70e82f18828ed603e49e3db5a0d234e307a3419a4c98ad1c4209
Target: 0xe37844e3800a70e82f18828ed603e49e3db5a0d234e307a3419a4c98ad1c4209
Address: 0xe37844e3800a70e82f18828ed603e49e3db5a0d234e307a3419a4c98ad1c4209
Account type: contract
Balance: 0 PYDE (0 quanta)
Nonce: 0
Code hash: 0x4ac6059a67dea1d8b2f9c3a7e15d8b4c026a91e8f3d7b5a9c14e6f2b8d3a5c79e
Code size: 5077 bytes
State root: 0x0000000000000000000000000000000000000000000000000000000000000000
ABI summary (from embedded pyde.abi custom section):
abi version: 1
contract type: contract
functions: 2
constructor: init
state schema: 0x9c2e1a7d4f3b6e58a01d92f74c83b6e2d75a4f1c8b39e6d20a47f15b29e3c8a64
0x6f3a82c1 get [entry, view]
0xa8e21f74 increment [entry]
Two flags are useful here — --at-wave <N> reads state at a historical wave (archive-node-only today; v1 forwards the value but falls back to current state otherwise), and --rpc-url <URL> lets you inspect without a local otigen.toml (e.g. against operator dashboards or a forked devnet).
What's shown:
Account type—eoa,contract, orsystem. The chain'sAccountTypediscriminant.Balance— current balance as<N> PYDE (<M> quanta)(1 PYDE = 10⁹ quanta).Nonce— next acceptable nonce. The chain uses a 16-slot sliding window; this number isnonce_window.base + bitmap.trailing_ones().Code hash—Poseidon2(runtime_wasm). Zero for EOA / system accounts; non-zero for deployed contracts.Code size— length of the deployed bytecode in bytes.State root— Blake3 summary of the contract's storage sub-trie. (V1 keeps this all-zero; the chain uses one global JMT.)
There is no version / total_versions / owner / status surface in v1 inspect — the engine doesn't carry those fields on Account (Lifecycle covers what v1 actually provides and the v2 plan).
Read a state field
For contracts written with #[pyde::declare_storage] (the substrate path, used by every otigen new template), use --state-field:
otigen inspect <addr> --state-field counter
Contract: 0xe37844e3800a70e82f18828ed603e49e3db5a0d234e307a3419a4c98ad1c4209
Field: counter (uint64)
Slot: 0xbb4077a4bc85738f57b9b7e95e40b473eeed3c6bb6d0b7b4f9f49718bd903511
Slot bytes: 0x0300000000000000
Value: 3
--state-field derives the slot Poseidon2(self_address ‖ field_name) (matching the chain's sstore_scalar / sload_scalar host fns) and decodes the bytes per the type token declared in your otigen.toml's [state].schema. Unset slots render as <unset>.
For legacy pre-substrate contracts (those that called sload / sstore directly with their own derive_slot helper), use --field:
otigen inspect <addr> --field counter
--field derives Poseidon2(name.as_bytes()) — the convention the hand-written examples used before the substrate macros existed. Picking the wrong flag returns the wrong slot; both produce a hash that hits an unset slot rather than failing loudly, so match the flag to how the contract was written.
For mapping fields the slot derivation includes the key; the inspect surface for mapping reads is currently best-driven through otigen call <addr> <view-fn> (next section), which routes through the contract's typed getter rather than computing the slot externally.
Call a view function
otigen inspect does not invoke contract code — it only reads state directly. To call a view function (read state through the contract's own logic), use otigen call:
otigen call <addr> get
Call get on 0xe37844e3800a70e82f18828ed603e49e3db5a0d234e307a3419a4c98ad1c4209 (devnet)
Target: 0xe37844e3800a70e82f18828ed603e49e3db5a0d234e307a3419a4c98ad1c4209
RPC: http://127.0.0.1:9933
Mode: view (pyde_call — no tx, no gas, no nonce)
✓ Call succeeded.
Return: 3
otigen call without --from runs in view mode against pyde_call — no tx, no gas, no nonce, no signing. Pass --from <wallet> to switch to a state-mutating signed call. Positional arguments are typed per [functions.<fn>].inputs in declaration order:
otigen call <addr> balance_of 0x9b8c7d6e5f4a3b2c...
otigen call <addr> balance_of devnet-0
Address-typed inputs accept either a 0x-prefixed 64-char hex literal OR a wallet name from the local keystore (devnet-0, alice, …). The CLI looks the wallet name up and substitutes its 32-byte address before encoding the call. See the call command reference for the full typed-args surface (vec(T), structs, enums, the --raw view-return knob).
For the structured-output variant, add --json:
otigen call <addr> get --json
The emitted call_included NDJSON event includes a return_data field with the hex-encoded bytes — useful for scripted consumers.
2. otigen verify
The reproducibility check. Pulls the on-chain bytes, recomputes them locally, compares.
otigen verify <addr>
Target: my-counter
Address: 0xe37844e3800a70e82f18828ed603e49e3db5a0d234e307a3419a4c98ad1c4209
Network: devnet
Bundle: ./artifacts/my-counter.bundle/
Local wasm: 5077 bytes, blake3 4ac6059a67dea1d8…
Chain wasm: 5077 bytes, blake3 4ac6059a67dea1d8…
✓ Match — bundle is byte-identical to the deployed contract.
The CLI fetches pyde_getContractCode(addr), re-derives the Blake3 hash of your local ./artifacts/<name>.bundle/contract.wasm, and compares both byte length and hash. The --strict-toolchain flag also compares the toolchain version pin baked into the bundle's manifest.json against the running rustc / TinyGo / asc / clang — useful when reproducing audited builds.
If they don't match:
✗ MISMATCH
Size differs: local 5077 vs chain 4989 (88 bytes)
First differing byte at offset 419
Hint: same source + toolchain pins should produce identical bundles.
Check rustc / wasm-target / opt-level / strip settings.
Possible causes:
- The contract was deployed from different source. Re-build from the source the deployment actually used, then re-verify.
- The chain shipped tampered bytes (only possible if you don't trust the RPC). Query a second RPC and compare.
Submit to an external verifier
otigen verify <addr> --explorer https://explorer.pyde.network --api-key-env PYDE_EXPLORER_KEY
Uploads (contract.wasm, manifest.json, metadata.json) to a verifying explorer's /api/v1/contracts/<addr>/verify endpoint. The --api-key-env variant reads the bearer token from an env var; --api-key-stdin reads from stdin. The CLI redacts the key when echoing the endpoint.
Set EXPLORER_API_KEY and the --api-key-env flag is optional; pass --api-key-env <VAR> only when the key lives under a different env name. --api-key-stdin takes priority over --api-key-env.
Why verify matters
Three scenarios it catches:
- Build drift. Two team members build from the same commit and get different bundles. Verify catches it before the inconsistency makes it to production.
- Supply-chain interference. Someone substitutes a bundle between
otigen buildandotigen deploy. Verify catches it after deploy. - Compromised RPC. A malicious RPC serves modified bytes. Verify catches it if you trust the chain's actual storage but not the gateway.
For auditors: re-build from source on a clean machine, run otigen verify. Mismatch ⇒ either the deployed contract was modified post-deploy, or the source you have isn't the source that was deployed. Both are red flags.
3. Off-chain queries via RPC
For programmatic access, the chain exposes a JSON-RPC. The same RPC otigen inspect uses under the hood. See Chapter 17 — Developer Tools for the full catalog. Relevant methods for contract state:
| Method | What it returns |
|---|---|
pyde_chainId | The chain id as 0x...-hex (0x7a69 = 31337 for devnet). |
pyde_getAccount | Account metadata (type, balance, nonce, code_hash, state_root). |
pyde_getContractCode | The deployed WASM bytes (what otigen verify calls). |
pyde_getStorageSlot | Read a specific slot by its 32-byte hash. |
pyde_call | Execute a view function (free, no tx, no gas charged to a wallet). |
pyde_estimateGas | Estimate gas for a write call. |
pyde_getTransactionReceipt | Fetch a tx receipt by hash. |
pyde_resolveName | Resolve a contract name to its address. |
Example raw call:
curl -X POST http://127.0.0.1:9933 \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "pyde_call",
"params": [{
"to": "0xe37844e3800a70e82f18828ed603e49e3db5a0d234e307a3419a4c98ad1c4209",
"data": "0x03000000676574000000000000000000000000"
}]
}'
The data field is the borsh-encoded pyde_engine_types::CallPayload { function: "get", calldata: vec![] } envelope — the same wire shape tx.data carries for a TxType::Standard call.
{
"jsonrpc": "2.0",
"id": 1,
"result": "0x0300000000000000"
}
The result is the contract's borsh-encoded return value as hex. For u64 the bytes are little-endian (0x03... = 3). The CLI's view-mode otigen call decodes this for you; the raw RPC leaves it to the caller.
What's next
You can now deploy a contract, query its state, and prove the on-chain bytes match your local source. The remaining piece of the lifecycle is operating it over time: upgrading the logic, pausing it during incidents, retiring it permanently. That's Lifecycle.
Lifecycle
Operating a deployed contract over time: upgrading the logic, pausing it under incident, retiring it permanently.
Honest status (v1). The four CLI subcommands —
otigen upgrade,otigen pause,otigen unpause,otigen kill— exist and sign correctly, but the chain has noTxType::Lifecyclehandler yet. Submitting one today is refused at the CLI by anEngineNotReadygate (see §4 below). PerOTIGEN_BINARY_SPEC §8.2/§8.3, v1 ships no chain-side upgrade/pause/unpause/kill tx types. The patterns in §2 (proxy upgrades, author-declared pause / kill booleans) are how you get the same operational outcomes today.
1. What the chain provides today
| Need | v1 path | v2 path (planned) |
|---|---|---|
| Replace contract logic | Proxy + delegate_call; admin swaps the implementation slot | Native upgrade tx → engine swaps the code blob at the same address |
| Halt entrypoints under incident | Author-declared paused: bool in [state]; every entry asserts !paused | Native pause flag on Account set by a pause tx |
| Retire a contract irreversibly | Author-declared killed: bool; every entry reverts when set | Native kill tx zeroing the contract's code_hash |
| Tie any of the above to an owner | Author-managed in storage; the contract is its own authority | Account.deployer enforced by the engine |
Two things to internalize:
- There is no native "contract owner" concept in v1. Accounts have
auth_keys, but a contract account is its own authority surface. Authoring an "owner" means storing anAddressin[state]and checkingpyde::ctx::caller() == stored_ownerin your guarded entrypoints. The CLI's lifecycle commands assume engine support that does not exist; they cannot enforce ownership for you today. - The CLI surface is committed in code so the day the engine catches up the wire shape doesn't shift. Until then, the four subcommands refuse to submit. See the engine ask tracking
TxType::Lifecycle+ paused/killedAccountfields for the proposed shape.
2. The v1 patterns
2.1 Upgrade — the proxy pattern
The canonical v1 upgrade story is a proxy contract that holds the admin + logic address in storage and forwards every call via pyde::call::execute_delegate_raw. To upgrade you deploy a new logic contract and submit a tx that overwrites the logic slot.
The upgradeable-proxy template is the worked example. The skeleton is two files. In src/lib.rs:
#![allow(unused)] fn main() { pyde::declare_storage!(); const ZERO_ADDRESS: Address = [0u8; 32]; #[pyde::entry] fn init(initial_logic: Address) { // Re-init guard: the manifest tags `init` as `["constructor"]`, // and this in-source check makes the invariant explicit. if storage::proxy_admin().read() != ZERO_ADDRESS { pyde::revert("proxy: already initialized"); } let admin = pyde::ctx::caller(); if admin == ZERO_ADDRESS || initial_logic == ZERO_ADDRESS { pyde::revert("proxy: init with zero address"); } storage::proxy_admin().write(admin); storage::proxy_logic().write(initial_logic); } #[pyde::entry] fn upgrade_to(new_logic: Address) { let admin = storage::proxy_admin().read(); if pyde::ctx::caller() != admin { pyde::revert("proxy: caller is not admin"); } if new_logic == ZERO_ADDRESS { pyde::revert("proxy: upgrade to zero address"); } storage::proxy_logic().write(new_logic); } #[pyde::entry] fn transfer_admin(new_admin: Address) { let admin = storage::proxy_admin().read(); if pyde::ctx::caller() != admin { pyde::revert("proxy: caller is not admin"); } if new_admin == ZERO_ADDRESS { pyde::revert("proxy: transfer to zero address; use renounce_admin"); } storage::proxy_admin().write(new_admin); } #[pyde::entry] fn renounce_admin() { let admin = storage::proxy_admin().read(); if pyde::ctx::caller() != admin { pyde::revert("proxy: caller is not admin"); } storage::proxy_admin().write(ZERO_ADDRESS); } #[pyde::entry] fn forward(function: String, calldata: Vec<u8>) -> Vec<u8> { let logic = storage::proxy_logic().read(); match pyde::call::execute_delegate_raw(&logic, &function, &calldata) { Ok(bytes) => bytes, Err(CallError::Reverted(payload)) => { let msg = core::str::from_utf8(&payload) .unwrap_or("proxy: delegate-call failed"); pyde::revert(msg); } Err(CallError::InvalidFunction) => { pyde::revert("proxy: logic has no such function"); } Err(_) => pyde::revert("proxy: delegate-call failed"), } } }
renounce_admin is the one-way door: zeroing the admin slot freezes the logic pointer forever, so the contract becomes non-upgradeable from that point on.
In otigen.toml the storage layout is declared declaratively:
[state]
schema = [
{ name = "proxy_admin", type = "address" },
{ name = "proxy_logic", type = "address" },
{ name = "value", type = "uint64" },
]
The proxy_ prefix on the privileged fields is intentional. Pyde's storage slots are derived as Poseidon2(self_address || field_name), and under delegate-call the logic sees the proxy's self_address — so a logic contract that happens to declare a field named admin would otherwise clobber the proxy's admin slot. Prefixing makes the collision a loud, deliberate choice rather than a silent footgun.
The proxy address never changes. Storage lives in the proxy. The logic contract is a pure code blob — its address rotates each upgrade. Callers point at the proxy's address forever.
Trade-offs vs a native engine upgrade:
- Cost: every call pays a
delegate_callindirection — flat 1,200 gas + 8 gas per calldata byte on top of the sub-call's own gas (perHOST_FN_ABI_SPEC§7.8). - Storage discipline: the logic's storage slot derivation lives in the proxy's address space. Renaming a field is a wire break across upgrades (the slot hash changes); use append-only field order.
- Admin key risk: lose the admin key, lose upgradability. Pair with a multisig for production (see
simple-multisig). Lifecycle ops then go through multisig proposals + signature collection.
2.2 Pause — author-declared boolean
Add a paused: bool field and assert it at every state-mutating entrypoint. Reads stay open by convention.
#![allow(unused)] fn main() { pyde::declare_storage!(); fn require_unpaused() { if storage::paused().read() { pyde::revert("contract: paused"); } } fn require_owner() { if pyde::ctx::caller() != storage::owner().read() { pyde::revert("contract: not owner"); } } #[pyde::entry] fn pause() { require_owner(); storage::paused().write(true); } #[pyde::entry] fn unpause() { require_owner(); storage::paused().write(false); } #[pyde::entry] fn transfer(to: Address, amount: u128) { require_unpaused(); // ... transfer logic } }
The matching otigen.toml [state] block declares owner: address, paused: bool, plus whatever other fields your contract needs.
In-flight transactions that were already accepted into the mempool before the pause tx commits will still execute (the pause only affects waves committed AFTER the pause). View calls via otigen call <addr> <view-fn> always work regardless; they don't enter consensus.
2.3 Kill — author-declared terminal flag
Same shape as paused, but the entry assertions never check for an unpause counterpart. Once set, the contract refuses every mutation forever.
#![allow(unused)] fn main() { fn require_alive() { if storage::killed().read() { pyde::revert("contract: killed"); } } #[pyde::entry] fn kill() { require_owner(); storage::killed().write(true); } }
Storage is retained on-chain — there is no v1 mechanism to free the contract's slot space or release its name. If a future chain release adds a native kill tx, the engine ask proposes zeroing code_hash (effectively deleting the bytecode while keeping the address registered to prevent name-squatting).
3. What the CLI subcommands do today
The four subcommands (upgrade, pause, unpause, kill) are scaffolded against the future TxType::Lifecycle wire shape:
otigen upgrade my-counter --bundle ./artifacts/my-counter.bundle --from deployer
otigen pause my-counter --from deployer
otigen unpause my-counter --from deployer
otigen kill my-counter --from deployer --yes
All four sign txs with tx_type = Standard and a borsh-encoded LifecyclePayload in tx.data. The engine sees a Standard tx to a contract address, tries to decode tx.data as a CallPayload { function: String, calldata: Vec<u8> }, and reverts with decode CallPayload: Unexpected length of input — burning gas on a guaranteed-failed tx.
The CLI refuses to submit by default to avoid that gas burn (see §4). Until the engine ships TxType::Lifecycle, prefer the §2 patterns.
4. The EngineNotReady gate
Run any of the four lifecycle commands today and the CLI refuses up-front:
otigen [ERROR] EngineNotReady: `pause` lifecycle ops are not yet wired
on the chain side (no TxType::Lifecycle handler, no paused/killed
Account fields). Submitting this tx would revert with
`decode CallPayload: Unexpected length of input` and consume gas.
See the engine ask at /tmp/pyde-engine-lifecycle-ask-2026-06-18.md.
hint: pass `--i-know-engine-rejects` to bypass this gate
(e.g., to exercise the CLI signing path against a stub
engine).
Exit code is 1 (VALIDATION_FAILURE — same code as --rpc-url without --chain-id).
When you'd ever want the bypass
--i-know-engine-rejects is for two narrow cases:
- CLI development against a stub engine. Submitting the tx exercises the FALCON signing path, the wave-canonical tx-hash computation, and the wallet keystore flow. The tx itself reverts but everything up to submission is real.
- CI / regression tests that mock the chain side. The wire bytes are still meaningful for test fixtures.
For everyday contract work — don't pass it. Burn no gas on a guaranteed-failed tx.
# Will be refused (correctly):
otigen pause my-counter --from deployer
# Will submit (and the engine will revert, and you'll burn gas):
otigen pause my-counter --from deployer --i-know-engine-rejects
5. Required flag pair for any submitting subcommand
Every signing CLI subcommand (deploy, upgrade, pause, unpause, kill) carries a --rpc-url + --chain-id pair. They're optional in isolation but coupled when used:
# Default: read RPC + chain_id from otigen.toml's [network.<name>]
otigen upgrade my-counter --from deployer --i-know-engine-rejects
# Override RPC URL (e.g. running against an alt port). REQUIRES --chain-id.
otigen upgrade my-counter \
--from deployer \
--i-know-engine-rejects \
--rpc-url http://127.0.0.1:29933 \
--chain-id 31337
Passing --rpc-url without --chain-id returns InvalidArgs with exit 1. The CLI refuses because the resolver returns chain_id = 0 on the raw-URL path, and signing a tx against chain_id = 0 silently bricks the FALCON signature against the chain's tx-hash domain. The pair has to travel together.
6. Owner key hygiene (forward-looking)
When the engine catches up and lifecycle ops actually submit, the on-chain Account.deployer field will gate them. Until then, treat the auth_keys of whatever account you used to deploy + write storage::owner with the same level of paranoia.
-
Don't reuse your dev keystore for production deployments. Spin a separate wallet via
otigen wallet new prod-owner. -
Plan for a multisig. The path forward is to set the multisig contract's address as the proxy's admin (and as
storage::ownerfor any direct-pause contracts). Lifecycle ops then go through multisig proposals + signature collection. -
Test the upgrade flow on devnet before mainnet. The canonical drill:
otigen devnet --rpc-listen 127.0.0.1:9933 otigen new my-proxy --lang rust --from upgradeable-proxy otigen deploy --from deployer # then sign a tx that calls upgrade_to(new_logic) on the proxy
What's next
Debugging catalogs the error surfaces you'll hit and how to recover — including the EngineNotReady gate above, the --rpc-url + --chain-id consistency check, and the common deploy + call failure modes.
Debugging
Errors you'll hit, in the order you typically hit them. Each entry has the symptom (verbatim error message), the cause, and the fix.
If your error isn't here, raise the global verbosity (-v / -vv) — every subcommand emits INFO + DEBUG level logs that usually expose the root cause.
1. Installation errors
error: unable to create target: 'No available targets are compatible with triple "wasm32"'
Cause: clang lacks the wasm32 backend. On macOS, Apple's bundled /usr/bin/clang doesn't include it.
Fix: brew install llvm then add brew's clang to PATH:
export PATH="/opt/homebrew/opt/llvm/bin:$PATH"
Verify with clang -print-targets | grep wasm32.
clang: error: unable to execute command: posix_spawn failed: No such file or directory
Cause: clang found its wasm32 backend but wasm-ld (the LLVM WASM linker) is missing.
Fix: brew install lld then add to PATH:
export PATH="/opt/homebrew/opt/lld/bin:$PATH"
lld is a separate brew formula from llvm. Installing one doesn't install the other.
tinygo: command not found after brew install tinygo
Cause: TinyGo isn't in default homebrew formulae. You need their tap first.
Fix:
brew tap tinygo-org/tools
brew install tinygo
(using go version <unknown>...) when running tinygo version
Cause: Go isn't installed alongside TinyGo.
Fix: brew install go.
error obtaining VCS status: exit status 128 when running tinygo build
Cause: TinyGo's underlying Go compiler stamps the binary with VCS info and refuses to build outside a git repo.
Fix: git init -q in the project directory.
ToolchainMissing: TinyGo requires `wasm-opt` (binaryen) for size optimisation
Cause: the compile preflight detected that TinyGo is selected but wasm-opt (shipped in binaryen) is not on PATH. TinyGo invokes wasm-opt for its -opt=z size pass and fails without it.
Fix: install binaryen.
# macOS
brew install binaryen
# Debian / Ubuntu
sudo apt install binaryen
# Arch
sudo pacman -S binaryen
Or point TinyGo at a custom build: export WASMOPT=/path/to/wasm-opt.
asc: command not found after npm install -g assemblyscript
Cause: npm's global bin directory isn't on $PATH.
Fix:
echo "export PATH=\"$(npm config get prefix)/bin:\$PATH\"" >> ~/.zshrc
source ~/.zshrc
error[E0463]: can't find crate for std (Rust)
Cause: you forgot rustup target add wasm32-unknown-unknown.
Fix:
rustup target add wasm32-unknown-unknown
2. Build errors
otigen build and otigen check print otigen [ERROR] BuildRejected: <N> validation issue(s) followed by bullets — one bullet per violated rule. The variants below match the Display of the engine's ValidationError enum.
import "<module>"."<name>" is forbidden; the only allowed module is "pyde"
Cause: the WASM imports a function outside pyde::*. Common offenders:
| Import | Cause |
|---|---|
env.abort | AssemblyScript's default panic handler. See §4 below. |
wasi_snapshot_preview1.fd_write | Compiling with -target=wasi instead of -target=wasm-unknown (TinyGo) or --target=wasi instead of --target=wasm32 (C). |
env.<libc-fn> | Linking against libc. C contracts must use -nostdlib. |
Fix: disable the source that emits the offending import.
import pyde.<name> is not in the host function allowlist
Cause: the contract imports a pyde::* function not yet in the chain's host-fn surface (typo in the import name, or a v2-only fn).
Fix: check spelling against HOST_FN_ABI_SPEC §7. If the fn is legitimately missing from v1, find an alternative pattern.
import pyde.<name> is parachain-only; this contract is not declared as a parachain
Cause: the contract calls a §8 parachain-only host fn (parachain_storage_*, parachain_id, etc.) but otigen.toml has [contract] type = "contract".
Fix: either drop the parachain-only call, or set type = "parachain" in otigen.toml.
function "<name>" exports the WASM signature ... but the spec requires () -> () for every entry point
Cause: the entry point's WASM signature isn't void-void. Either a hand-rolled #[no_mangle] pub extern "C" fn foo(args, ...) -> ret (pre-spec shape), or your macro substrate didn't fire.
Fix: use #[pyde::entry] from pyde-entry-macros. The macro generates the spec's () -> () shim that reads args from pyde::calldata_* and returns via pyde::return.
WASM module exports function "<name>" but it is not declared in otigen.toml
Cause: the WASM exports a function not declared in otigen.toml's [functions.<name>] table.
Fix: declare it (add a [functions.<name>] entry), or rename the symbol to start with _ (internal helpers are excluded from the check).
function "<name>" is declared in otigen.toml but the WASM module does not export it
Cause: the inverse — otigen.toml declares a function but the WASM doesn't export it.
Fix: in your source, mark the function with the language's WASM-export attribute. For Rust, #[pyde::entry] fn <name>(...) is the canonical shape (the macro adds #[no_mangle] pub extern "C" for you).
WASM module uses a forbidden feature outside Pyde's deterministic subset: <wasmparser diagnostic>
Cause: the WASM uses a feature outside the deterministic subset (threads, SIMD, GC, reference types, multi-memory, memory64, component model).
Fix: find the language compiler flag that disables the feature. For AssemblyScript, check asconfig.json — simd: false, threads: false.
3. Increasing verbosity
The standard global -v flag, repeated:
otigen test # default — per-test pass/fail + duration
otigen test -v # + INFO logs from the runner
otigen test -vv # + DEBUG logs (host-fn calls, slot derivations)
otigen test --json # NDJSON event stream for CI / scripting
otigen test --dry-run # parse + resolve only, no execution
The same -v works on every subcommand. There is no Foundry-style four-level trace ladder today; failing assertions print expected-vs-actual; storage diffs live in expect.storage.* declarations in the test TOML.
For runtime-engine vs legacy-mock-runner bisection, otigen test --no-engine falls back to the legacy in-process mock host-fn surface (useful when you need parity to confirm an engine-runner-side issue).
4. AssemblyScript aborts
The single most common AS issue.
Symptom
otigen [ERROR] BuildRejected: 1 validation issue(s)
- import "env"."abort" is forbidden; the only allowed module is "pyde"
Cause
AssemblyScript's compiler emits env.abort calls for runtime checks (array bounds, integer overflow, unreachable()). The default is to import an abort function from the host environment. Pyde rejects non-pyde imports.
Fix
asconfig.json must include:
"options": {
"use": ["abort=assembly/index/abort"]
}
And assembly/index.ts must define an abort function (the init template does this automatically):
function abort(
_message: string | null = null,
_fileName: string | null = null,
_line: u32 = 0,
_column: u32 = 0,
): void {
unreachable();
}
This substitutes the default env.abort with an in-contract abort() that traps via unreachable(). No env import, deterministic crash.
The function must NOT be export'd — exporting it makes it a public dispatch surface that Pyde then rejects as ExportedButNotDeclared.
5. Runtime errors
wasm trap: out of fuel
Cause: the contract's wasmtime fuel ran out. Either an infinite loop, or the per-call gas budget is too low.
Fix:
-
Raise the per-test fuel ceiling in the test's
[cheats]:[cheats] gas_limit = 5_000_000_000 # 5B fuel (~ 5M gas at the 1000 fuel/gas conversion)Pyde maps gas → fuel at
FUEL_PER_GAS = 1000. Today the legacyotigen testrunner treatscheats.gas_limitas raw fuel units (default cap 1 B fuel ≈ 1 M gas); the engine path uses gas units (default 10 M gas, converted internally to 10 B fuel). When you seewasm trap: out of fuelandcheats.gas_limitis unset, you've hit the default cap. Raise it explicitly to the fuel budget you need. -
Or find + fix the infinite loop in the contract. Common cause: a loop with a wrong termination condition that never trips.
wasm trap: error while executing at wasm backtrace: ...
Cause: a WASM trap during a call. Specific cause varies; the backtrace is usually unhelpful in release builds (stripped symbols).
Fix: raise verbosity to -vv to see the host-fn call sequence that preceded the trap. Then check the contract code for:
- Array out-of-bounds (panic → trap)
unreachable!()orcore::arch::wasm32::unreachable()called- Stack overflow in deeply-nested calls
If you can't figure it out, compile with debug info (--profile dev or equivalent), re-run — backtraces will then carry function names. Deploy validation rejects debug builds, so don't ship them.
Reverted: <reason>
Cause: the contract explicitly called pyde::revert("<reason>"). The runner classifies the halt as a revert (not a trap) — the receipt's status is reverted and the reason string surfaces in return_data / revert_reason.
Fix: this is the contract author's intentional path — confirm the revert is the one you meant. In a .test.toml, assert it with the substring matcher:
[[tests.calls]]
function = "withdraw"
args = ["1000"]
expect.revert = "InsufficientBalance"
If you're hitting a revert you don't expect, the reason string is your first signal — print it via -v to see it inline with the failed call.
6. Deploy + tx submission errors
EngineNotReady: <op> lifecycle ops are not yet wired on the chain side
Cause: you ran otigen upgrade / pause / unpause / kill. The chain has no TxType::Lifecycle handler yet; the CLI refuses to submit a tx that's guaranteed to revert.
Fix: for v1 use the patterns in Lifecycle:
- Upgrade: the proxy pattern with
delegate_call(see theupgradeable-proxytemplate). - Pause / Kill: author-declared
paused: bool/killed: boolin[state]+ guard every entrypoint.
To exercise the CLI signing path against a stub engine (CI / development), pass --i-know-engine-rejects — the tx WILL revert on chain and burn gas, by design.
InvalidArgs: --rpc-url for deploy requires --chain-id (signed tx needs a chain id to verify)
Cause: you passed --rpc-url without --chain-id. The resolver returns chain_id = 0 on the raw-URL path, which silently bricks the FALCON signature against the chain's tx-hash domain.
Fix: pair them. Match --chain-id to what the running RPC reports via pyde_chainId:
otigen deploy --rpc-url http://127.0.0.1:9933 --chain-id 31337 --from devnet-0 --password-stdin <<< pw
RpcError(submitting deploy tx): storage backend: insufficient balance: have <N> need <M>
Cause: the signing wallet's balance is below the deploy fee.
Fix: fund the wallet. On devnet, the canonical path is otigen wallet import --from-devnet — that imports the 10 prefunded devnet-0..devnet-9 accounts the embedded otigen devnet bootstraps at genesis. There is no POST /faucet HTTP endpoint on the devnet RPC; the prefund-at-genesis path is the only auto-funding the binary provides.
RpcError(submitting <op> tx): nonce <N> not acceptable (sender base=<M>)
Cause: Pyde uses a 16-slot sliding nonce window per account. The tx's nonce is below nonce_window.base or ≥ base + 16. Either a stale tx is in the mempool, or your local nonce cache is out of sync with the chain.
Fix: wait for the in-flight tx to commit or expire, then retry. The CLI re-queries the nonce on each submission, so a retry after a few seconds usually unsticks it. There is no --nonce override flag today.
InclusionTimeout: tx 0x... not included after 60s (mempool may still hold it — re-query via pyde_getTransactionReceipt later)
Cause: the receipt poll exceeded the 60-second timeout (constant — not CLI-configurable). The tx may still commit later.
Fix: re-query via pyde_getTransactionReceipt directly (or otigen call <hash> if you're checking a call). For chains under stress, this is informational; for an idle devnet it usually means the tx was rejected silently — check the devnet log.
RpcError(...): connection refused / connect timeout
Cause: the RPC endpoint isn't reachable.
Fix:
- For devnet: confirm
otigen devnet --rpc-listen 127.0.0.1:9933is running and listening on the port you're hitting. - For testnet / mainnet: the canonical RPC URL is
https://rpc.<network>.pyde.network. Use--rpc-url+--chain-idto override the project config.
UnknownNetwork: <name> not declared in [network.*]
Cause: you passed --network <name> (or your otigen.toml has [network.default] name = "<name>") but no [network.<name>] block declares the endpoint, and the name isn't a built-in.
Fix: add the entry to otigen.toml:
[network.<name>]
rpc_url = "https://rpc.<name>.pyde.network"
chain_id = <id>
…or bypass the lookup with --rpc-url <url> --chain-id <id> for a one-shot run.
7. View-call debugging via --json
For programmatic consumers (CI, integration harnesses), otigen call --json emits NDJSON. View-mode calls include the contract's return value as return_data (hex):
otigen call <addr> get --json
{"event": "call_start", "target": "<addr>", "function": "get", "network": "devnet", "chain_id": 31337}
{"event": "call_included", "tx_hash": "", "status": "success", "return_data": "0x0300000000000000"}
For view-mode calls, tx_hash is the empty string ("") — view calls go via pyde_call and don't create a tx; the field is kept for JSON-shape symmetry with write-mode events.
Write-mode calls (with --from) omit return_data today — the receipt poll-helper doesn't surface success-path return data yet. The human-readable Return: 0x... line is view-mode only.
8. Verify mismatches
MISMATCH (verify exits 1 with a hash + size + first-differing-byte diff)
Cause: the on-chain bundle doesn't match your local rebuild.
Possible causes:
- Contract was redeployed between your build and the verify. Re-pull the latest source, re-build, re-verify.
- Build is non-deterministic. Common cause:
Cargo.lockdiffers (you didn't commit one). Runcargo build --lockedto enforce the lock file. - Toolchain version differs. Your
otigen.tomlrecords the toolchain pin; if your local toolchain doesn't match, the build is reproducible-different. Verify withrustup show/tinygo version/ etc., or add--strict-toolchainto fail loudly on mismatch.
If none of those apply, file an issue — reproducibility is a load-bearing property of the toolchain; a real divergence is a real bug.
StrictToolchainMismatch: bundle was built with <tool> <X>; host has <tool> <Y>. Reproducibility check failed.
Cause: you passed --strict-toolchain and your host's rustc / TinyGo / asc / clang version doesn't match what the bundle's manifest.json recorded.
Fix: install + activate the matching toolchain, or rebuild without --strict-toolchain if you're knowingly working at a different pin.
9. Where to get help
- Inline: every
otigen <command> --helpgives subcommand-specific usage. - Spec docs: OTIGEN_BINARY_SPEC + OTIGEN_TEST_SPEC + HOST_FN_ABI_SPEC. The spec is authoritative on documented behavior; the binary's
--helpis authoritative on shipped behavior. Where they disagree, the binary wins for "what runs today" and the spec describes the target. - Examples:
pyde-net/otigen/examples/. The 8 scaffold-able templates that build cleanly today are listed in Examples and viaotigen new --list. - Issues: https://github.com/pyde-net/otigen/issues for toolchain bugs, https://github.com/pyde-net/pyde-book/issues for doc gaps.
Examples
The fastest way to start a new contract is to clone one of the canonical templates:
otigen new my-contract --from <template-name>
The eight templates otigen new --list exposes are the curated entry points — each one demonstrates a concrete pattern with a working [state] schema, host-fn usage, and (where applicable) a TOML test suite. Beyond the eight, the otigen/examples/ directory carries additional reference contracts that aren't yet promoted to first-class scaffold templates — clone them with git if you want to study them.
Scaffold-able templates
What otigen new --list returns today, with honest status:
| Template | Status | What it demonstrates |
|---|---|---|
counter | ✅ builds, 3/3 tests green | Minimum viable contract — single u64 counter via pyde::declare_storage!{} + #[pyde::entry]. The default otigen new counter --lang rust scaffold, and the starter member otigen init seeds a workspace with. |
erc20-token | ✅ builds, 1/1 test green | ERC20-style fungible token. Typed-arg marshalling: otigen call automatically encodes function arguments per [functions.<fn>].inputs (e.g. address, u128) — see Typed arguments in the command reference. Mapping + composite-key mapping (balances, allowances). |
erc721-token | ✅ builds, 17/17 tests green | ERC721-shape NFT. Per-token ownership, balance_of(owner), single-spender per-token approval cleared atomically on transfer_from. |
upgradeable-proxy | ✅ builds, 16/16 tests green | Upgradeable proxy via delegate_call. Admin-controlled implementation slot with transfer_admin / renounce_admin rotation and namespaced proxy_admin / proxy_logic storage slots. |
dao-governance | ✅ builds, 13/13 tests green | FALCON-signed votes + time phases + hash_blake3-committed execution. The most-composed v1 example. |
simple-multisig | ✅ builds, 14/14 tests green | 3-signer FALCON-512 multisig. Demonstrates falcon_verify + signer-ID lookup + action_digest(target, amount, nonce) view for off-chain signers + nonce-bound replay protection. |
merkle-claim-airdrop | ✅ builds, 17/17 tests green | Merkle-tree airdrop claim. Off-chain commitment + on-chain inclusion verification via hash_blake3. Macro substrate; Vec<u8>-typed proof argument. Ships a #[payable] fn fund() so the contract custodies native PYDE end-to-end and pays out on claim. |
vesting | ✅ builds, 21/21 tests green | Linear vesting with cliff. Time-locked allocation via wave_timestamp. Ships a #[payable] fn fund() so the contract holds native PYDE and releases it to the beneficiary as time accrues. |
Reference contracts in the examples/ tree
These live on disk but aren't (yet) promoted to first-class otigen new templates. Clone them via git if you want to study a specific pattern:
| Reference | Pattern |
|---|---|
hello-rust | Minimal void-void entry + pyde::return without the #[pyde::entry] macro — useful for understanding the macro's expansion. |
counter-rust | Source of the counter scaffold template. Identical surface; included for direct browsing. |
counter-go / counter-as / counter-c | Same counter surface ported to TinyGo / AssemblyScript / C. The starter each otigen new --lang <go|as|c> (or the first member of an otigen init --lang <go|as|c> workspace) scaffolds. |
counter-pair-a + counter-pair-b | Cross-contract calls via pyde::cross_call. Test runner pre-deploys both via [[contracts]] in the test TOML. |
x-call-caller + x-call-target | Typed cross-contract call surface — caller wraps pyde::call::execute_call against a target that exposes a declared [functions.*] surface. |
proxy-logic-v1 + proxy-logic-v2 | Two implementation versions used as delegate targets by upgradeable-proxy end-to-end tests. Useful for understanding the upgrade-path data flow. |
amm-uniswap-v2 | Uniswap-v2-shape constant-product AMM. Pair contract with reserves + LP-share accounting. Largest worked example. |
escrow, multisig-wallet, nft-marketplace, payment-channel | Higher-order patterns. Status varies — read each example's README.md. |
profile-registry | First parachain example. Variable-length-keyed storage exercising the v1-mocked parachain_storage_* host fns. |
borsh-coverage, struct-storage, state-and-emit | Type-coverage + storage-encoding reference contracts. |
*-smoke, *-stress, e2e-soak | Test fixtures consumed by otigen's own CI — not contracts you'd scaffold from. |
This is a curated subset — see the examples/ tree for the full catalog.
To clone one of these into a fresh project:
git clone https://github.com/pyde-net/otigen
cd otigen/examples/<name>
# Read the README, copy the bits you need into your own project tree.
There is no otigen new --from <reference> path for these yet — they aren't in the template registry.
Running an example end-to-end
# Scaffold from a template:
otigen new my-counter --lang rust --from counter # or omit --lang on a TTY and pick it interactively
cd my-counter
# Build + test the local way:
otigen build
otigen test
# Or, against a live devnet:
otigen devnet --rpc-listen 127.0.0.1:9933 & # in another terminal
otigen deploy --from devnet-0 # banner shows BOTH `my-counter` (registered name) and 0x… hex
otigen call my-counter increment --from devnet-0 # by registered name
otigen call my-counter get # view mode — no --from
Verbose test output (with gas, events, traces, storage diffs) is available via:
otigen test -v # + gas used per test
otigen test -vv # + emitted event list (topic0 + sizes)
otigen test -vvv # + per-call traces (fn args / return / gas)
otigen test -vvvv # + storage diffs (slot → before / after)
When to add a new example
The examples/ directory carries the reference contracts; the otigen new --list registry carries the curated subset users land on first. To promote an existing reference to the scaffold registry (or add a wholly new one), the template needs to:
- Demonstrate a host fn or pattern not yet covered by the eight current templates.
- Compile cleanly under the current
HOST_FN_ABI_SPEC§3.5.2 entry shape — use#[pyde::entry]for Rust, not the pre-spec#[no_mangle] pub extern "C". - Ship a
tests/contract.test.tomlthat passesotigen testagainst the live source. - Stay under ~200 lines of contract code unless the pattern genuinely needs more.
When in doubt, counter and erc20-token are the calibration points for "right-sized canonical demo".
Pyde Technical Design
Version 0.1
This is the comprehensive technical design document for Pyde. For high-level pitch, see WHITEPAPER.md. For operational specs, see the individual documents linked below.
Table of Contents
- Layered Architecture
- Consensus: Mysticeti DAG
- Cryptography
- Execution Layer
- State Layer
- Account Model
- Transaction Lifecycle
- Encryption & MEV Resistance
- Network Protocol
- Performance Targets
- Implementation Status
Layered Architecture
Pyde is a monolithic blockchain (consensus + execution + state in single binary) with these layers:
┌─────────────────────────────────────────────┐
│ Application │
│ WASM smart contracts, dApps, wallets, RPC │
├─────────────────────────────────────────────┤
│ Execution │
│ WebAssembly (wasmtime + Cranelift AOT), │
│ Block-STM scheduler, MVCC, access-list │
│ prefetch (PIP-3) │
├─────────────────────────────────────────────┤
│ State │
│ Jellyfish Merkle Tree (JMT) │
│ Hybrid: Blake3 native + Poseidon2 for ZK │
├─────────────────────────────────────────────┤
│ Consensus │
│ Mysticeti DAG, anchor selection, finality │
├─────────────────────────────────────────────┤
│ Cryptography │
│ FALCON-512, Kyber-768 threshold, DKG │
├─────────────────────────────────────────────┤
│ Network │
│ libp2p + QUIC, Gossipsub, worker/primary │
└─────────────────────────────────────────────┘
Consensus: Mysticeti DAG
Algorithm Choice
Pyde uses Mysticeti-style DAG consensus (Mysten Labs' production protocol on Sui). Chosen over Bullshark for faster commit latency (~390ms vs ~1s) and better liveness under validator failures.
Why DAG over HotStuff:
- No single-proposer bottleneck — every committee member contributes vertices continuously
- No view changes — eliminates the bug class that caused Pyde's pre-pivot wedges
- Censorship resistance — 127 honest members can include any transaction; censorship requires near-unanimous collusion
- Throughput scales with committee size, not constrained by one proposer's bandwidth
- Threshold-decryption integrates naturally at the order-commit boundary
Worker / Primary Split (Narwhal Pattern)
Each validator runs:
- Workers (N per validator): handle tx ingress, build batches, gossip batches peer-to-peer
- Primary (one per validator): handles consensus — produces vertices, gathers parents, signs state roots
Transactions travel the network exactly once (via worker gossip). Consensus vertices stay tiny (carry only batch hashes by reference).
Vertex Structure
#![allow(unused)] fn main() { struct Vertex { round: u64, member_id: u32, // validator address as u32 internally batch_refs: Vec<BatchHash>, // hashes of batches I have parent_vertex_refs: Vec<VertexHash>, // ≥85 round-(N-1) vertex hashes state_root_sigs: Vec<StateRootSig>, // attestations on recent commits prev_anchor_attestation: VertexHash, // attests prior round's anchor decryption_shares: Vec<DecryptionShare>, // piggybacked partials falcon_sig: FalconSig, // sig over the vertex } }
Each vertex is dual-role: header (declaring what data I have) AND attestation (acknowledging prior-round vertices via parent_vertex_refs). Parent references are implicit "votes" — no separate vote messages.
Rounds & Anchors
A round is a layer in the DAG, advancing when a member collects ≥85 parent vertices.
Each round has a deterministically-selected anchor:
anchor_member_id = Hash(beacon, round, prev_state_root) mod 128
The prev_state_root lookback (N=3 rounds) limits anchor predictability to ~450ms (down from a full epoch).
A commit fires when the anchor vertex has sufficient support (Mysticeti 3-stage support). Multiple commits can be in flight; ~95% of rounds commit successfully.
Commit
1. Anchor selected by deterministic rule
2. Anchor's subdag walked via parent_vertex_refs (transitive)
3. Subdag sorted: (round, member_id, list_order)
4. Batches dereferenced from each vertex
5. Threshold decryption ceremony runs (pipelined — partials pre-broadcast)
6. ≥85 partials combine per batch → plaintexts revealed
7. wasmtime executes in canonical order
8. State root computed (Blake3 + Poseidon2 dual)
9. ≥85 committee FALCON-sign state root (piggybacked on next vertices)
10. Finality declared
End-to-end latency: ~500ms median for plaintext, ~700ms for encrypted (decryption ceremony adds ~200ms within wave budget).
Committee
- Size: 128 validators per epoch
- Selection: uniform random from all validators with stake ≥
MIN_VALIDATOR_STAKE(10,000 PYDE). Single tier — every staked validator meeting the floor is in the same pool - Anti-Sybil: operator identity binding, max 3 validators per operator
- Equal power: all 128 have equal voting weight, vertex production rate, anchor probability
- Stake influence: only on eligibility + flat 30% pool yield share. Activity rewards within committee are contribution-weighted, not stake-weighted.
- Epoch length: ~3 hours (measured in wall-clock, not in round count, so it's stable across consensus-cadence changes)
- DKG ceremony: runs in background during prior epoch's last minutes
Safety & Liveness
- Safety: Mysticeti BFT — holds under any network with at most
f = 42Byzantine members (BFT tolerance⌊(n-1)/3⌋for n = 128) - Liveness: holds under partial synchrony
- Recovery: explicit halt detection + investigation + recovery (see CHAIN_HALT.md)
- Rollback: bounded to 1 epoch (3 hours) via governance multisig; beyond that, only hard fork
Cryptography
Signatures: FALCON-512
NIST FIPS 206 standard. Used for:
- User transaction authorization
- Validator vertex production
- Committee state-root attestations
- Decryption share authentication
Properties:
- Public key: 897 bytes
- Signature: 666 bytes
- Verification: ~80μs commodity CPU
- Post-quantum secure (lattice-based)
Threshold Encryption: Kyber-768
NIST FIPS 203 standard, with threshold variant.
- Public key: 1184 bytes (one per epoch, shared across all encrypters)
- Ciphertext overhead: ~1088 bytes + plaintext size
- Decryption: requires ≥85 of 128 partial decryptions combined via Lagrange interpolation
Critical invariant: commit-before-reveal. Consensus orders encrypted transactions before any decryption shares are released. Decryption happens after ordering is final.
v1 risk: production-grade threshold variants of lattice schemes (Kyber threshold) are research-stage. Pyde v1 may ship with classical-crypto threshold (ElGamal-style) as transitional measure, migrating to threshold Kyber when audited implementations mature. This is the single largest cryptographic engineering risk in the design.
Hash Functions: Hybrid Layered Strategy
| Use case | Hash | Why |
|---|---|---|
| JMT internal nodes | Blake3 | ~30× faster than Poseidon2 on CPU |
| State root (published) | Both | Blake3 native verification + Poseidon2 for ZK |
| Transaction hashes | Blake3 (ciphertext), Poseidon2 (plaintext canonical) | Per use |
| Address derivation | Poseidon2 | Used in sig-verify ZK circuits |
| FALCON sig hashing | Poseidon2 | Inside ZK aggregation circuit |
| Vertex hashes | Blake3 | Small volume, no ZK |
Random Beacon
Each epoch's beacon is produced by the previous epoch's committee:
- All 128 members sign a known message
"epoch_N_beacon"with threshold-share keys - ≥85 shares combine into deterministic aggregated signature
beacon_N = Hash(aggregated_signature)→ 32 bytes randomness- Published in last wave of epoch N
Properties: deterministic given shares, unpredictable until reveal, bias-resistant.
DKG (Distributed Key Generation)
Pedersen DKG, multi-round protocol (~30-60s runtime):
Round 1: Each member generates random secret polynomial f_i(x), degree 84
Round 2: Each member broadcasts public commitments to coefficients
Round 3: Member i sends f_i(j) to each other member j (encrypted)
Round 4: Member j verifies received values, sums s_j = Σ f_i(j) = f(j)
where f(x) = Σ f_i(x) is the combined polynomial
Result: Each member j holds s_j = f(j) (private share)
SK = f(0) is never computed; PK derivable from public commitments
Threshold = 85
Mathematical foundation: any 85 points on a degree-84 polynomial uniquely determine it (Lagrange interpolation), enabling 85+ members to perform partial decryptions that combine without anyone reconstructing SK.
Partial Decryption Math
Given ciphertext (c1, c2) where c1 = g^r, c2 = m · PK^r:
Each member i: partial_i = c1^(s_i)
Combine via Lagrange (any subset S of 85):
combined = Π_{i in S} partial_i^(λ_i)
= c1^(SK)
= PK^r
Decrypt: m = c2 / combined
SK is never assembled. Each member's s_i is reusable across many ciphertexts within the epoch.
Execution Layer
WASM Execution Layer (wasmtime)
WebAssembly via wasmtime, with Cranelift ahead-of-time compilation:
- WebAssembly Core Specification as the instruction set (industry-standard, externally maintained)
- Deterministic feature subset enforced (NaN canonicalization on; threads, non-deterministic SIMD, reference types, GC, multi-memory, memory64, WASI all disabled)
- Fuel-based gas metering (wasmtime's built-in mechanism)
- Per-contract module compilation cache (Cranelift AOT artifacts persisted)
- Deploy-time validator rejects modules with forbidden imports or non-deterministic features
- Host-function ABI is the only chain-side surface contracts can reach
Determinism is load-bearing. Same input transactions must produce byte-identical state transitions across all validators (consensus safety) and feasible ZK circuits (future validity proofs). wasmtime's determinism config + deploy-time validator together provide this guarantee.
Smart Contract Authoring
Contracts are authored in any wasm32-target language (Rust, AssemblyScript, Go via TinyGo, C/C++). The otigen developer toolchain handles the lifecycle: project scaffolding (otigen init), build with state binding generation (otigen build), deploy (otigen deploy), upgrade governance, wallet management.
Pyde safety attributes (preserved from Otigen-language era):
- Reentrancy off by default (opt-out via
reentrantattribute) - Checked arithmetic (wrapping ops require explicit opt-in)
- Typed storage via
[state]schema inotigen.toml - No
tx.origin(host function ABI exposes onlycaller) - View / payable / reentrant / sponsored / constructor attributes
- Compile-time static access list inference (from declared state schema)
- 4-byte function selectors
Build output: .wasm artifact + JSON ABI + deploy bundle.
Block-STM Parallel Scheduler
Pyde uses uniform Block-STM (Aptos-style) as the v1 execution model. Every tx in a committed wave runs optimistically in parallel through an MVCC layer, with conflicts caught at validation and losers re-executing until fixpoint. Full algorithm + determinism contract: BLOCK_STM_EXECUTION.md.
Access lists from pyde_simulateTransaction are prefetch hints only — the scheduler unions every declared (addr, slot) pair across the wave and issues one batched state_cf.multi_get (PIP-3) into the dashmap (PIP-4) before Block-STM workers start. Lists are never used to partition the wave or affect correctness; if a list is wrong, the missed slots just miss the warm-cache fast path, Block-STM still produces the correct deterministic result.
Why uniform Block-STM over a static-list / Block-STM hybrid for v1: single execution path means single test surface, single determinism contract, single bug class. Aptos's measured production numbers (10-30K real-world TPS) match Pyde's v1 target. The access-list-driven scheduling fast path stays available as a v2 throughput lever — see "Path Beyond v1" in BLOCK_STM_EXECUTION.md.
Pyde-specific opportunity: controls compiler, runtime, language, and protocol — the wallet's pyde_simulateTransaction round-trip means the chain is the only one where every tx already arrives with an accurate access list, making prefetch coverage near-100% in steady state.
Preflight Execution
Users request access list + gas estimate via RPC before signing:
Client → pyde_estimateAccess(tx)
→ RPC runs a wasmtime preflight (dry-run against current state)
→ Returns: { gas_estimate, access_list }
Client attaches access_list to tx, signs
State staleness handled by treating access list as a hint — scheduler verifies at runtime, falls back to Block-STM on mismatch.
State Layer
Jellyfish Merkle Tree (JMT)
Radix-16, path-compressed Merkle tree (Diem/Aptos lineage):
- ~5–10 nodes per state operation (vs SMT's ~256)
- Substantial I/O savings at high TPS
- Standard authentication properties (commitment, inclusion/exclusion proofs)
State Root Commitment
Dual-rooted:
- Blake3 root: fast native verification (used by validators)
- Poseidon2 root: ZK-circuit-friendly (future light clients, validity proofs)
Both computed at each commit, both signed by committee.
State Pruning
| Node type | State retention |
|---|---|
| Archive node | All historical state |
| Full node (default) | Last 90 days |
| Committee validator | Last 30 days |
| Light client | Headers + cared-about accounts |
See STATE_SYNC.md for sync protocol details.
Account Model
Account State
#![allow(unused)] fn main() { struct Account { nonce: u64, balance: u128, gas_tank: u128, // pre-deposited for encrypted tx submission auth_keys: AuthKeys, // Single | Multisig | Programmable code_hash: Hash, // for contract accounts storage_root: Hash, // for contract storage (JMT subtree) key_nonce: u32, // FALCON key rotation counter } enum AuthKeys { Single(FalconPubkey), Multisig(M, Vec<FalconPubkey>), // M-of-N, max 16 Programmable, // reserved for v2 } }
Nonce Window
Pyde uses a 16-slot sliding nonce window instead of strict sequential nonces:
#![allow(unused)] fn main() { struct NonceState { base: u64, // lowest unused nonce used: u16, // 16-bit bitmap of consumed slots in [base, base+15] } }
Allows up to 16 concurrent in-flight transactions per account, out-of-order within the window. Standard EVM-style nonces force head-of-line blocking; Pyde's window decouples submission ordering from execution ordering.
Native Multisig (v1)
AuthKeys::Multisig(M, [pubkey_1, ..., pubkey_N]) requires M valid FALCON signatures over the tx hash. Max 16 signers. Used for treasuries, DAOs, exchange custody.
Significantly safer than contract-based multisig (Gnosis Safe model on Ethereum), which reimplements the same logic across projects with subtle bugs.
Programmable Accounts (v2)
Reserved enum variant at v1. When v2 ships:
- Account has signing keys AND attached WASM policy module
- Policy runs on every authorization, can implement: spend limits, time locks, allow-listed recipients, social recovery, tiered authorization, AI agent delegation
- Same fields as contracts (
code_hash+storage_root) - WASM "policy mode" — restricted state access during validation
Session Keys (v2)
Scoped, bounded, revocable delegation. The user authorizes a session key once; the dApp (or agent) signs many transactions on the user's behalf within the declared scope.
Type:
#![allow(unused)] fn main() { struct SessionKey { pubkey: FalconPubkey, scope: SessionScope, expires_at: WaveId, revoked: bool, } struct SessionScope { contracts: Vec<Address>, methods: Vec<Selector>, // optional; empty = all methods on allowed contracts max_spend: u128, spent_so_far: u128, // mutable, updated at tx commit } }
Registry. Session keys are stored under the account's programmable-policy state subtree. The slot_hash clusters with the account under PIP-2 so lookups during authorization are local. New keys are added by RegisterSessionKey txs signed under the main auth_keys; existing keys are revoked by RevokeSessionKey txs.
Authorization-time check (pseudocode):
fn authorize_session_tx(tx) -> Result<(), AuthError> {
let sk = lookup_session_key(tx.session_key_id)?;
// 1. Signature
verify_falcon(sk.pubkey, tx.hash, tx.session_sig)?;
// 2. Liveness
require(current_wave < sk.expires_at, KeyExpired);
require(!sk.revoked, KeyRevoked);
// 3. Scope
require(sk.scope.contracts.contains(&tx.to), OutsideContractScope);
if !sk.scope.methods.is_empty() {
require(sk.scope.methods.contains(&tx.selector), OutsideMethodScope);
}
// 4. Spend cap
let new_spent = sk.scope.spent_so_far + tx.value;
require(new_spent <= sk.scope.max_spend, ExceedsSpendCap);
// On commit:
// sk.scope.spent_so_far = new_spent;
Ok(())
}
Use cases:
- Gaming — sign once, play many actions.
- AI agents — bounded delegation (e.g., "trade at most 100 PYDE/day on this DEX until next Friday").
- Consumer apps — subscriptions, micro-transactions.
- Embedded wallets — passkey-style flows where the main key never leaves a secure enclave.
Limits.
- Maximum 32 active session keys per account (anti-squat).
max_spendis monotonic — increasing it requires a new key, not a mutation.expires_atcannot exceedcurrent_wave + MAX_SESSION_WAVES(default: ~30 days at 500ms/wave = ~5.18M waves).
v1 reservations: AuthKeys::Programmable enum tag 0x03, account code_hash + storage_root fields, WASM policy-mode execution flag, multisig signature pipeline. All present at genesis; only the policy engine and session-key registry need to be added at v2.
Threat-model entries for session keys live in companion/THREAT_MODEL.md §Authorization Layer (added v0.2).
Transaction Lifecycle
Plaintext Transaction
User wallet:
1. Construct unsigned tx (sender, recipient, amount, nonce, gas, payload, deadline)
2. RPC pyde_estimateAccess(tx) → returns gas_estimate + access_list
3. Attach access_list to tx
4. FALCON-sign tx hash
5. Submit: pyde_sendRawTransaction(signed_tx)
RPC node:
6. Verify wire format, size, chain_id
7. Forward to nearest validator worker
Worker:
8. Verify FALCON sig at ingress
9. Verify nonce within window, balance, gas
10. Batch with other txs
11. Gossip batch to peer workers
Primary (every ~150ms):
12. Produce vertex referencing batches + parents
13. Gossip vertex
14. Peer primaries cert via next-round parent refs
Commit (per round, ~390ms median):
15. Anchor selected; subdag walked; canonical order emitted
16. wasmtime executes in canonical order:
- Nonce window check (state may have changed)
- Balance check
- Access list verification (vs runtime)
- Hybrid scheduler partitions txs into parallel groups
- Execute, apply state diffs
17. JMT updated, state root computed (Blake3 + Poseidon2)
18. Committee FALCON-signs state root, ≥85 collected
19. Finality declared
Encrypted Transaction
Same as above, with:
- Step 4.5: After FALCON-sign, Kyber-encrypt signed_tx with epoch PK
- Step 5: pyde_sendRawEncryptedTransaction(encrypted_blob)
- Worker step 8: cannot verify sig (encrypted) — only verify wire format
- Commit step 15.5: threshold decryption ceremony — ≥85 partials combine per encrypted tx (batches contain a mix of plaintext + encrypted txs) → plaintexts revealed. Share-combine is batched across the wave for amortised cost.
- Then wasmtime step 16 includes first sig verification
Encryption & MEV Resistance
Three structural defenses, layered:
Layer 1: Threshold Encryption
Users encrypt time-sensitive transactions (DEX swaps, NFT mints, liquidations) before submission. Encrypted blob is opaque — even committee members cannot decrypt alone.
Layer 2: Commit-Before-Reveal
Consensus orders encrypted transactions before decryption shares are released. By the time content is revealed, ordering is fixed and irreversible.
Layer 3: No Proposer
Pyde's DAG consensus has no single party empowered to choose which transactions enter a commit or in what order. The canonical order emerges deterministically from the DAG; no member can selectively reorder, exclude, or front-run.
Combined effect: sandwich attacks, front-running, proposer extraction are structurally impossible — not policed, not auctioned, not made more efficient. The ordering primitive itself doesn't admit them.
Encryption is Optional
Per-tx choice via envelope:
pyde_sendRawTransaction— unencrypted, fast path, no MEV protectionpyde_sendRawEncryptedTransaction— encrypted, MEV-resistant, costs more gas
Wallets default to "auto" — encrypt time-sensitive, skip for simple transfers.
Encryption bandwidth cost: ~70% reduction if 80% of txs are unencrypted simple transfers (typical mix).
Network Protocol Summary
See NETWORK_PROTOCOL.md for full details.
Key choices:
- Transport: QUIC over UDP (no HOL blocking, built-in TLS 1.3)
- Library: libp2p (Rust) — mature, audited
- Peer discovery: layered (hardcoded → DNS → on-chain registry → PEX → cache); no DHT
- Gossip: Gossipsub with per-topic meshes
- DoS: 4-layer (connection/message/peer-scoring/application)
- Committee defense: sentry node pattern (Cosmos-style)
Performance Targets
Honest Targets
Validated by multi-region production-realistic harness (see PERFORMANCE_HARNESS.md):
| Metric | v1 baseline | Stretch (post-mainnet) | Aspirational |
|---|---|---|---|
| Plaintext throughput (commodity) | awaiting harness | awaiting harness | awaiting harness |
| Encrypted throughput (commodity CPU) | awaiting harness | awaiting harness | awaiting harness (GPU) |
| Median finality | ~500ms | ~400ms | ~300ms |
| Committee NIC requirement | 500 Mbps | 1 Gbps | 10 Gbps |
Publishing Discipline
- Publish only what the harness measures under sustained, production-realistic conditions.
- Never lab extrapolations, microbenchmark peaks, or single-machine numbers where multi-region is the relevant scope.
- Aspirational figures are labelled "production validation pending" and carry no concrete number.
No external TPS claim without harness evidence.
Hardware Tiers
| Role | Hardware |
|---|---|
| Light client | Mobile / browser |
| Full node / RPC | 8c / 16GB / 500GB / 100 Mbps |
| Non-committee validator | 8c / 16GB / 500GB / 100-250 Mbps |
| Committee (v1 baseline) | 8-16c / 32GB / 1TB SSD / 500 Mbps |
| Committee (Stretch, post-mainnet) | 16c / 32GB / 2TB SSD / 1 Gbps |
| Committee (Aspirational, GPU-class) | 32c / 64GB / 4TB SSD / 10 Gbps |
Modest hardware applies to any validator awaiting committee selection at all levels. Active-committee hardware scales with the throughput target. The aspirational tier is tied to GPU-acceleration / batch-decryption research advances per the honest performance targets above.
Implementation Status
This documentation reflects designed architecture, not shipped implementation:
| Component | Status |
|---|---|
| Architecture design | ✅ Complete |
| WASM execution layer (wasmtime + Cranelift AOT) | 🟡 Foundation in place; integration in progress; programmable-accounts hooks + Block-STM scheduler + access-list prefetch integration pending |
| State layer (JMT) | 🟡 In place, needs hybrid hashing |
| Consensus (Mysticeti-style) | 🔴 Not yet — rebuild post-pivot |
| Threshold cryptography | 🔴 Research-grade (PQ threshold is bleeding-edge) |
| Network protocol (libp2p) | 🟡 Existing in archive, needs migration |
| Performance harness | 🔴 Not yet built |
| Slashing + lifecycle | 🟡 Partial in archive |
| State sync | 🟡 Partial design |
| Documentation | 🟡 This is the current state |
Mainnet ships when the work above is complete and the external audit passes. No public schedule.
Highest-risk piece: post-quantum threshold cryptography. Research-stage, may require classical-crypto transitional v1 with migration to PQ threshold in v2 as standards mature.
Cross-References
| Topic | Document |
|---|---|
| Threats & adversaries | THREAT_MODEL.md |
| Operational failures | FAILURE_SCENARIOS.md |
| Halt + recovery procedures | CHAIN_HALT.md |
| Slashing rules | SLASHING.md |
| Validator state machine | VALIDATOR_LIFECYCLE.md |
| State sync protocol | STATE_SYNC.md |
| Network protocol | NETWORK_PROTOCOL.md |
| Performance harness | PERFORMANCE_HARNESS.md |
| Token economics | TOKENOMICS.md |
Document version: 0.1
License: See repository root
Pyde: A Post-Quantum, MEV-Resistant Layer 1 with Mysticeti-style Consensus
Version 0.2 — 2026 Pyde Network · Apache-2.0
Abstract
Pyde is a Layer 1 blockchain built greenfield to ship four properties as defaults from genesis. The four are technical commitments; each translates into concrete outcomes for the businesses building on Pyde, the developers writing for it, and the users transacting on it.
-
Post-quantum cryptography by default. FALCON-512 signatures, Kyber-768 threshold encryption, Poseidon2 + Blake3 hybrid hashing. No pre-quantum primitive on any consensus or account path. For businesses, long-tail agreements — insurance policies, multi-year escrows, intellectual-property registries, legal records — remain cryptographically valid into the quantum era without a coordinated migration to budget for. For users, funds remain secure as quantum computing matures.
-
MEV resistance at the protocol layer, not via a trusted relayer. Threshold-encrypted mempool + commit-before-reveal ordering. Sandwich attacks, front-running, and proposer extraction are not policed or auctioned; they are structurally impossible. For users, this means trades execute at the price signed; for businesses, no invisible tax on customer transactions and no third-party relayer to opt into and trust.
-
Sub-second finality. Mysticeti-style consensus, ~500 ms median commit finality, an 85-of-128 FALCON quorum certificate. For users, transactions confirm immediately rather than after a 12-second spinner; for businesses, settlement completes before checkout abandonment kicks in, and every confirmed transaction carries a portable cryptographic receipt that compliance teams verify offline.
-
Commodity-hardware decentralization. Full nodes and validators awaiting committee selection run on 8 cores / 16 GB RAM. Validators on the active committee at production throughput require a 500 Mbps – 1 Gbps NIC; every committee seat carries one vote regardless of stake. Enterprises that want to verify the chain independently can do so at hardware costs measured in thousands per year — not the $20K+/month that production-grade validators on the highest-throughput chains run.
The execution layer is WebAssembly via wasmtime (with Cranelift AOT) and a uniform Block-STM scheduler — every tx runs optimistically in parallel through an MVCC layer, conflicts are caught at validation, losers re-execute until fixpoint. Wallet-attached access lists from pyde_simulateTransaction drive PIP-3 multiget prefetch into the dashmap cache before workers start; the lists are performance hints, not scheduling decisions, and never affect correctness. Developers author smart contracts in Rust, AssemblyScript, Go (TinyGo), or C/C++ — any wasm32-target language — with Pyde safety attributes (reentrancy off by default, checked arithmetic, typed storage, no tx.origin, compile-time access-list inference) preserved as language-native attributes and enforced at runtime. No proprietary VM or new language to learn; teams use the stack they already know. The otigen developer toolchain handles project scaffolding, build, state binding generation, and deployment. Cross-chain interactions are served by the parachain framework (v1) and post-mainnet bridge contracts gated by HardFinalityCert — a FALCON quorum certificate verifiable on any chain. The parachain framework lets developer teams launch their own execution environments (custom VMs, confidential-vote chains, gaming-specific subchains, oracle networks) without slot auctions or central gatekeeping; Pyde validators stake to run third-party parachains and earn the parachain's fees.
This document presents the current design following a 2026 architectural pivot from an in-house HotStuff variant (whose persistent wedges and stalls at 400 ms slot timing motivated a clean rebuild) to a DAG-based consensus inspired by Narwhal, Bullshark, and Mysticeti. The pivot scoped the chain to its execution and cryptography layers first; the consensus layer is being rebuilt design-first against the new foundation.
The v1 mainnet throughput target — for both the plaintext and encrypted regimes, on commodity validator hardware — is established by a multi-region performance harness before any number is published. Long-term aspirational headroom (with GPU acceleration, batch threshold decryption, and protocol upgrades) is real but carries no concrete number and is not a v1 commitment. The chain commits to publishing only what the harness measures under sustained, production-realistic conditions — never lab extrapolations or microbenchmark peaks — so any number it eventually publishes is one application teams and businesses can plan against rather than aspire to.
1. The Problem
Four open architectural debts run across the production L1 set today, each of them protocol-level rather than application-level, and each easier to ship at genesis than to migrate into a chain that has been running without it. The debts are paid by different parties — users lose to MEV; businesses absorb the cost of unpredictable fees and delayed settlement; developers carry the integration burden of bridge multisigs and proprietary languages — but they all trace to the same handful of design choices.
Quantum vulnerability. Every major L1 in production — Bitcoin, Ethereum, Solana, Cardano, Polkadot, Aptos, Sui — secures its consensus and account paths with classical cryptography (secp256k1, Ed25519, BLS12-381) that falls to Shor's algorithm on a cryptographically-relevant quantum computer. NIST's 2024 standardization of FALCON, ML-DSA, and ML-KEM unblocked the post-quantum primitives, but retrofitting them into a live chain with trillions of dollars at risk and deployed contracts hard-coded against pre-quantum key formats is a multi-year coordinated migration. The chains have not been blind to the problem; the constraint is the shape of the migration, not the seriousness of the response. Long-tail contracts written today on those chains carry an unbudgeted migration risk: insurance policies, multi-year escrows, intellectual-property registries, and other legal records that assume cryptographic validity over decades inherit whatever migration plan the host chain eventually executes.
MEV extraction. Maximum Extractable Value has hardened into a multi-billion-dollar tax paid by retail users to validator-builder coalitions. Sandwich attacks, front-running, and proposer extraction are not bugs to be patched — they are structural consequences of public mempools combined with single-proposer block production. The incumbent response has been to make the MEV market more efficient (proposer-builder separation on Ethereum, Jito on Solana). The alternative — removing the information asymmetry at the protocol level — is harder to retrofit because builder economics are now baked into the validator revenue model. Businesses building exchange, swap, or trading infrastructure on those chains either accept the tax on their customers or take on an opt-in dependency on a third-party relayer they must trust to behave.
Throughput at finality. Chains optimizing hardest for throughput have made validation a premium-hosting business. A Solana validator at production performance requires 12+ cores and 256+ GB RAM, costing $20K+/month. Chains optimizing for decentralization have ended up with throughput unusable for serious applications. The combination — sub-second hard finality at retail-scale throughput on commodity hardware — is the category no production chain occupies cleanly today. The trade-off lands on users (twelve-second confirmations, abandoned checkouts), on businesses (settlement delays, cash-flow friction, support tickets about transactions in flight), and on enterprises that wanted to verify the chain independently but were priced out of running their own validator.
Centralization at scale. Validation, smart-contract deployment, and cross-chain interaction have all converged toward gated infrastructure: data-center validators, custodial bridge multisigs ($3B+ lost in bridge hacks since 2021), oracle networks run by small operator coalitions, app-chain slots auctioned to well-capitalized parachain teams. Each is a coherent local response to the constraint set the chain in question faced; the cumulative cost lands on users (forced trust in operators they did not choose) and on developer teams (wanting to launch their own execution environment but unable to afford the slot auction or denied a place on the team's shortlist).
These four problems are not independent items. They converge in time. NIST's 2024 standardization matured the cryptographic primitives at the same moment that MEV literature converted into quantified user-cost numbers, at the same moment that Solana's hardware creep made the decentralization cost visible, at the same moment that L2 sequencer trust assumptions started attracting public scrutiny. The architecture that wins the next decade does not have to be the one that won the last one. No chain in production today provides all four properties as defaults. Pyde is the chain built to occupy that position.
2. Four Axioms
Every design choice in this document follows from four axioms.
Axiom 1 — Post-quantum cryptography is the default. No application-layer signature, encryption, or hash in Pyde uses pre-quantum primitives. FALCON-512 signs every consensus vote, every transaction, every validator key registration. Kyber-768 / ML-KEM encrypts every encrypted-mempool transaction. Poseidon2 (Goldilocks field) hashes ZK-bearing commitments; Blake3 hashes the high-volume native paths where ZK-friendliness is not in scope. Ed25519 appears only in libp2p's noise transport for peer routing — a quantum attacker who breaks Ed25519 learns the network topology but cannot forge a vertex, decrypt a transaction, or compromise an account.
The trade-off is signature size: 666 bytes for FALCON-512 versus 64 bytes for Ed25519, 1,088 bytes per Kyber-768 ciphertext versus negligible plaintext overhead. Pyde absorbs that cost in the layers that matter and avoids it everywhere it does not (e.g., gossip-level message authentication uses Blake3 + libp2p noise). For users, this means funds and identities remain secure as quantum hardware matures; for the ecosystem, long-tail agreements signed on Pyde — insurance policies, multi-year escrows, intellectual-property registries, legal records — don't carry an unbudgeted future-migration cost.
Axiom 2 — MEV is a protocol bug. No committee validator must be able to read, reorder, or selectively include unconfirmed transactions. This is a security property, not a market-design problem. Pyde achieves it with three interlocking mechanisms (Section 8 has the details):
- Transactions can be encrypted under a Kyber-768 threshold public key held jointly by the 128-validator committee — no fewer than 85 of 128 shares can decrypt.
- The committee commits to a canonical order at the DAG anchor before any decryption share is released. The order is fixed by the time content is visible.
- There is no single proposer. Order emerges deterministically from the DAG by every honest validator independently; no committee member can reorder, exclude, or front-run.
The combination removes the surface MEV extraction needs to exist on. Encryption is opt-in per transaction; simple transfers go plaintext for lower fees, MEV-sensitive operations (DEX swaps, NFT mints, liquidations) opt into encryption. For applications building exchange, swap, or trading infrastructure, this removes the choice between accepting a hidden tax on customer trades and opting into a third-party relayer they must trust to behave.
Axiom 3 — Throughput requires parallel execution in a single binary. Consensus and execution share a single process. The execution layer is a WebAssembly execution (wasmtime + Cranelift AOT) with a uniform Block-STM scheduler: every tx runs optimistically in parallel through an MVCC layer, conflicts caught at validation, losers re-execute until fixpoint. Wallet-attached access lists from pyde_simulateTransaction drive PIP-3 multiget prefetch into the dashmap cache before workers start; the lists are performance hints, not scheduling decisions. The choice is monolithic over modular: every cross-layer boundary is a trust boundary and a latency cost; for an L1 whose target is high-throughput low-latency MEV-resistant execution, coherence is worth more than heterogeneity. Cross-chain interoperability is added back as a separate permissionless parachain layer above the coherent base, not as a structural premise that fragments the chain at genesis. For investors evaluating execution-layer maturity, the monolithic-binary choice means one operational surface — one team's runbook, one set of audits, one performance harness — rather than the coordination cost of a microservices-style L1.
Axiom 4 — Decentralization is the protocol's burden, not the user's. Validators run on commodity hardware. Every committee member has exactly one vote regardless of stake — the validator bond is anti-Sybil cost, not a power multiplier. Cross-chain infrastructure is permissionless: any operator who stakes PYDE and runs a Pyde-published spec joins the parachain operator set, no auctioned slots, no gatekeeping team. The cost of participating in Pyde — running a node, validating, building a parachain — is a function of will and a small fixed bond, not access to data-center capital or auction proceeds. For developer teams wanting to launch their own execution environment, that means no slot auction to win and no foundation shortlist to make; for enterprises wanting to verify Pyde independently, the validator hardware sits comfortably inside the IT budget.
3. The 2026 Pivot
Pyde's earlier architecture used an in-house pipelined HotStuff variant with VRF proposer selection at 400 ms slot timing. Repeated wedges — head-divergence deadlocks, view-change cascades, quorum starvation under network jitter — were being addressed by accumulating patches rather than fundamental changes. The team made a clean break: remove the entire consensus, mempool, and networking layers from the active workspace and rebuild against a foundation with a smaller protocol surface and simpler safety arguments.
Post-pivot:
- The active engine workspace contains five execution-layer crates:
crypto,execution(the wasmtime-based WASM executor),state,account,tx. Nothing else. - Consensus, mempool, networking, slashing, and the node binary have been moved to a
archive/archive for reference. - The next consensus layer is being designed against the lessons of HotStuff failure: no view changes, no single-proposer bottleneck, data-driven round advancement, structural censorship resistance.
The decision: Mysticeti-style DAG consensus, with FALCON-bound vertex production and threshold-decryption ceremonies pipelined into the commit boundary. The remainder of this document describes the post-pivot design.
4. Architecture
Pyde is a monolithic Layer 1 chain — consensus, execution, and state in a single binary — with a layered protocol structure.
┌─────────────────────────────────────────────┐
│ Application Layer │
│ WASM smart contracts, dApps, wallets, RPC │
├─────────────────────────────────────────────┤
│ Execution Layer │
│ WebAssembly (wasmtime + Cranelift AOT), │
│ Block-STM scheduler, MVCC, access-list │
│ prefetch (PIP-3) │
├─────────────────────────────────────────────┤
│ State Layer │
│ Jellyfish Merkle Tree (JMT), hybrid hashing │
│ (Blake3 native + Poseidon2 ZK-bearing) │
├─────────────────────────────────────────────┤
│ Consensus Layer │
│ Mysticeti DAG, anchor selection, wave │
│ commit (rebuild in progress) │
├─────────────────────────────────────────────┤
│ Cryptography Layer │
│ FALCON-512 sigs, Kyber-768 threshold, DKG, │
│ threshold decryption, VRF │
├─────────────────────────────────────────────┤
│ Network Layer │
│ libp2p + QUIC, Gossipsub, worker / primary │
│ split (Narwhal pattern) │
└─────────────────────────────────────────────┘
Three operational tiers run the same binary; role differentiation is configuration:
| Tier | Stake | Committee role | Earns |
|---|---|---|---|
| Validator | 10K PYDE min (single tier) | Eligible for uniform-random committee selection each epoch | Reward-pool share (stake × uptime) + inflation share + activity-weighted bonus while on active committee |
| RPC node / full node | None | None | Off-chain RPC fees (market-set) |
5. Cryptography
5.1 FALCON-512 Signatures
Every transaction, vertex, and state-root attestation is signed with FALCON-512 (NIST FIPS 206). Properties:
- Signature size: ~666 bytes (variable, hard cap 1,280 bytes). Public key: 897 bytes.
- Verification: ~80 µs on commodity x86_64 / ARM64.
- No post-quantum BLS analog has matured, so consensus quorum certificates are the union of N FALCON signatures over a
voter_bitmaprather than a single aggregated signature. The mainnet bandwidth budget (500 Mbps – 1 Gbps NIC at the relevant TPS tier) is sized to absorb the QC size.
5.2 Kyber-768 Threshold Encryption
Pyde's encrypted mempool uses Kyber-768 (NIST FIPS 203) with a threshold variant. At each epoch the 128 committee members run a Distributed Key Generation ceremony producing one public key PK and 128 shares s_i. The threshold is 2f + 1 = 85 of 128 — the same quorum that gates commit and finality.
Transactions can optionally be encrypted under PK before submission. Decryption requires 85 committee members to compute partial decryptions and combine them by Lagrange interpolation. No coalition of fewer than 85 can decrypt anything — the unique secret only exists in distributed form.
Critical invariant — commit-before-reveal. Consensus commits to an order at the DAG anchor before any decryption share is released. By the time content is revealed, the order is fixed and irreversible. This is what eliminates MEV at the protocol layer.
5.3 Hybrid Hashing: Blake3 + Poseidon2
| Use | Hash | Reason |
|---|---|---|
| JMT internal nodes (high volume) | Blake3 | ~30× faster than Poseidon2 on CPU; not in ZK circuits |
| Published state root (per commit) | Both (Blake3 native + Poseidon2 ZK) | Native verification fast; ZK validity proofs future-compatible |
| Transaction hashes — ciphertext | Blake3 | Gossip / dedup, not in ZK |
| Transaction hashes — plaintext canonical | Poseidon2 | Inside sig-verify ZK circuits |
| Address derivation | Poseidon2 | Poseidon2(falcon_pk) exposed to sig-verify circuits |
| FALCON sig payload hashing | Poseidon2 | Inside ZK aggregation |
Poseidon2 over the Goldilocks field is the algebraic hash everywhere a future ZK proof would need to re-derive the value in-circuit; Blake3 is the high-throughput native primitive where ZK exposure is not in scope.
5.4 Randomness Beacon
Each epoch's beacon is produced by the previous epoch's committee via a threshold-signature ceremony on a known message. ≥ 85 shares combine into a deterministic aggregated signature; the hash of the signature is the beacon, 32 bytes. The beacon seeds:
- Per-round anchor selection:
anchor_member_id = Hash(beacon, round, prev_state_root) mod 128 - Next epoch's committee VRF picks
- Other protocol randomness
The prev_state_root term reduces anchor predictability from a full epoch (~ 3 hours) to a few rounds (~ 450 ms).
6. Consensus: Mysticeti-Style DAG
6.1 Why DAG (Why Not HotStuff)
The pre-pivot HotStuff variant exhibited persistent wedges and view-change cascades under realistic network conditions. The DAG approach removes the fragile parts:
| Problem in HotStuff | DAG resolution |
|---|---|
| Single proposer bottleneck | No proposer — every member contributes vertices each round |
| View-change protocol complexity | No view changes — eliminated an entire failure class |
| Timing-driven slot pipeline | Data-driven rounds advance with quorum, not clock |
| Proposer can selectively censor | 127 honest members can include any tx; censorship requires near-unanimous collusion |
| Throughput limited by leader bandwidth | Throughput scales with committee size |
The DAG also integrates cleanly with threshold decryption: the commit boundary is the natural place to run the decryption ceremony, with partial shares piggybacked on vertices in the rounds leading up to it.
6.2 Worker / Primary Split (Narwhal Pattern)
Each validator runs two roles:
- Workers (N processes): handle transaction ingress, build batches, gossip batches to peer workers.
- Primary (one process): handles consensus — produces vertices each round, gathers parent references, signs state roots, runs the DKG.
Transactions traverse the network once via worker gossip; consensus vertices stay tiny because they carry only batch hashes by reference (Section 6.3).
6.3 The Vertex
Each round, every committee member's primary produces exactly one vertex:
#![allow(unused)] fn main() { struct Vertex { round: u64, member_id: u32, batch_refs: Vec<BatchHash>, // hashes of batches I have parent_vertex_refs: Vec<VertexHash>, // ≥ 85 round-(N-1) hashes state_root_sigs: Vec<StateRootSig>, // attestations on recent commits prev_anchor_attestation: VertexHash, // attestation of prior anchor decryption_shares: Vec<DecryptionShare>, // piggybacked partials falcon_sig: FalconSig, // sig over the vertex } }
Parents must come strictly from the prior round (no skip edges in v1). The DAG is a consensus structure; transaction data lives in batches stored at the worker layer, referenced by hash.
Vertex size: typically ~ 830 bytes minimal, ~ 25 KB heavy (50 batches + 5 state-root sigs + 85 decryption-share partials); hard cap 64 KB.
6.4 Rounds, Anchor, and Commit
Rounds are data-driven: a member ticks from round N to N + 1 once it collects ≥ 85 valid round-N parents (the slowest 43 can lag without blocking anyone). Round rate: ~ 5–10 rounds / sec depending on network conditions.
Each round has a deterministically-selected anchor:
anchor_member_id = Hash(beacon, round, prev_state_root) mod 128
When the anchor vertex collects sufficient Mysticeti 3-stage support from later rounds, a commit fires:
- Anchor's subdag is collected by walking
parent_vertex_refstransitively. - The subdag is sorted deterministically:
(round, member_id, list_order). - Batches referenced by each vertex are dereferenced.
- For each encrypted transaction within those batches (encryption is per-tx, not per-batch), the threshold decryption ceremony runs (pipelined — partial shares are already in flight by commit time via the
decryption_sharesfield of vertices observed during the prior rounds). - wasmtime executes decrypted transactions in canonical order.
- State root is computed (Blake3 + Poseidon2 dual), FALCON-signed by ≥ 85 committee members.
- Finality is declared once ≥ 85 state-root signatures converge.
Median end-to-end finality target: ~ 500 ms. Validated by performance harness pre-publication.
6.5 Committee
128 validators per epoch, drawn from the global validator pool:
- Selection: uniform random from all validators with stake ≥
MIN_VALIDATOR_STAKE(10,000 PYDE). Single tier — no separate committee/non-committee stake floors. - Anti-Sybil: operator identity binding, max 3 validators per operator.
- Equal power: every committee member has equal voting weight, equal vertex production rate, equal anchor probability. Stake influences only (a) eligibility and (b) the proportion of the flat 30 % stake-pool yield share. Activity rewards are contribution-weighted, not stake-weighted.
- Epoch length: ~ 3 hours wall-clock (round count varies with network conditions).
- DKG: runs in the background during the prior epoch's last minutes; the new committee has the threshold key ready by epoch start.
6.6 BFT Properties
For n = 128: f = ⌊(n − 1) / 3⌋ = 42 is the maximum tolerable Byzantine count; the quorum threshold is 2f + 1 = 85. This single number appears throughout the protocol (vertex certification, commit support, threshold decryption, state-root sigs, DKG output) — consistency across uses avoids attack edges from boundary mismatches.
Safety holds under any network conditions assuming at most f = 42 Byzantine members. Liveness holds under partial synchrony.
6.7 Halts and Recovery
When safety appears at risk (e.g., contradictory state-root sigs), the protocol auto-halts. Three halt classes:
| Class | Trigger | Authority |
|---|---|---|
| Soft stall | Network / quorum slack | Emergent (auto-recovers) |
| Hard halt | Contradictory state roots, equivocation cluster, DAG fork | Protocol-detected automatic |
| Emergency halt | Off-chain bug report, active exploit, hard-fork prep | Governance multisig (7-of-12) |
Rollback is bounded to one epoch (~ 3 hours); within that window governance can authorize rollback to a prior consistent state. Beyond an epoch, only a coordinated hard fork is possible. This is the "weak finality with sunset" pattern — operational flexibility for early detection without arbitrary commit reversibility.
7. Execution: WebAssembly, Hybrid Scheduling
7.1 The WASM Execution Environment
Pyde executes smart contracts under wasmtime, the Bytecode Alliance's production WebAssembly runtime (in use at Microsoft, Fastly, Shopify):
- WebAssembly Core Specification: linear memory, structured control flow, validated bytecode, no syscalls
- Cranelift AOT compilation inside wasmtime — every module is compiled to native machine code at deploy time and cached; subsequent invocations re-use the compiled artifact, no JIT, no runtime recompile
- Fuel-based gas metering — every WASM instruction decrements a fuel counter at the basic-block level; when fuel hits zero, wasmtime traps and the transaction reverts
- Per-instance sandbox — each transaction runs in its own wasmtime instance with bounded linear memory (default 64 MB cap); the host (validator) decides which host functions are importable
- Host Function ABI for all chain interaction — storage (
sload/sstore/sdelete), balance and transfers, crypto (Blake3, Poseidon2, Keccak256, FALCON verify, threshold encrypt/decrypt), events, cross-contract and cross-chain calls - The retired Otigen language and custom
pyde-vmregister-based VM are preserved in the historical pivot record only; the active execution layer is wasmtime end-to-end
Determinism is load-bearing: the same WASM module with the same inputs and host-fn responses must produce byte-identical state transitions across all 128 committee members (consensus state-root agreement) and inside future ZK validity proofs over execution. Non-deterministic WASM features (threads, SIMD timing, floating-point environment) are disabled at module-validation time.
7.2 Smart Contract Authoring
Smart contracts are authored in any wasm32-target language (Rust, AssemblyScript, Go via TinyGo, C/C++). The otigen developer toolchain reads a otigen.toml, generates state bindings with pre-computed slot constants, invokes the correct language compiler, and produces a .wasm artifact plus JSON ABI:
- 30 keywords; storage maps, structs, enums, variable-length
Vec,String - 4-byte function selectors (EVM-compatible dispatch)
- Reentrancy guards (
#[reentrant]), checked arithmetic by default, custom errors and events #[view]/#[payable]/#[reentrant]function attributes- Compile-time static access-list inference: for each function, the compiler emits the set of storage slots it provably touches, plus regions where access depends on runtime values
- Block context is
block.anchor(the wave's canonical anchor vertex), notblock.proposer— Pyde's DAG has no single proposer, so contracts that depended onblock.proposeron other chains do not have an analog here
7.3 Block-STM Parallel Scheduler
Pyde uses uniform Block-STM as the v1 execution model. Every transaction in a committed wave runs optimistically in parallel through an MVCC layer, with conflicts detected at validation time and losers re-executing until fixpoint. The full algorithm + determinism contract: BLOCK_STM_EXECUTION.md.
Why uniform Block-STM, not a hybrid: single execution path means single test surface, single determinism contract, single bug class. Aptos's measured production numbers (10-30K real-world TPS) match Pyde's v1 throughput target. The access-list-driven scheduling fast path (Solana-style sequential within static-list groups) remains a v2 scaling lever — see "Path Beyond v1" in BLOCK_STM_EXECUTION.md — and the wire format already supports it without a future fork.
Access list as prefetch hint: Wallets request a runtime-observed access list via the pyde_simulateTransaction RPC (preflight execution against current state, returning {gas_used, access_list, status, return_data, events} in one call) and attach it to the signed tx. The scheduler unions every declared (addr, slot) pair across the wave and issues one batched state_cf.multi_get (PIP-3) into the dashmap (PIP-4) before Block-STM workers start. The list is a performance hint only — never used to partition the wave, never affects correctness. If the list is stale or wrong, the missed slots just miss the warm-cache fast path; Block-STM's MVCC still produces the correct deterministic result.
Pyde-specific opportunity: the chain controls compiler, runtime, language, and wallet, so the pyde_simulateTransaction round-trip means every tx arrives with an accurate access list, making prefetch coverage near-100% in steady state — a property no chain with a public mempool has.
7.4 Transaction Lifecycle
Wallet:
1. Construct tx (sender, recipient, amount, payload, ...)
2. RPC pyde_estimateAccess → returns gas_estimate + access_list
3. Attach access_list to tx
4. FALCON-sign tx hash
5. (Optional) Encrypt signed tx + access_list with epoch PK
6. Submit to RPC
Worker:
7. Validate wire format
8. (Plaintext) verify FALCON sig at ingress
9. Batch with other txs
10. Gossip batch to peer workers
Primary:
11. Produce vertex referencing available batches
12. Gossip vertex; peers attest as parents in next round
Commit (~500 ms median):
13. Anchor selected; subdag walked; canonical order emitted
14. (Encrypted) threshold-decrypt batches
15. wasmtime executes in canonical order
16. State root computed, signed by ≥ 85 committee members
17. Finality declared on 85 state-root sigs
End-to-end latency: ~ 500 ms median for plaintext, ~ 700 ms for encrypted (adds the decryption ceremony to the commit budget).
8. State: Jellyfish Merkle Tree
State is stored in a Jellyfish Merkle Tree (radix-16, path-compressed), persisted in RocksDB. Compared to a fixed-depth-256 Sparse Merkle Tree:
- ~ 5–10 nodes touched per state operation (vs ~ 256)
- Substantial I/O savings at high TPS
- Same authentication properties (Merkle commitment, inclusion / exclusion proofs)
- Production-proven (Diem, Aptos)
State commitment is dual-rooted at every commit: Blake3 for fast native verification by committee and validators, Poseidon2 for future ZK light clients and validity proofs. Both roots are signed by ≥ 85 committee members.
The block witness — every state slot touched by a wave plus a single batched JMT proof against the pre-state root — has a hard 1 MB cap, rejected at verification before any proof work runs.
9. MEV Resistance
Three structural defenses, layered:
Layer 1 — Threshold encryption. Users can encrypt transactions before submission. The encrypted blob is opaque even to committee members. Mempool sees only encrypted bytes; attackers cannot observe content to position around.
Layer 2 — Commit-before-reveal. Consensus orders encrypted transactions at the DAG anchor before any decryption share is released. By the time content is revealed, the order is fixed and irreversible.
Layer 3 — No proposer. Pyde's DAG consensus has no single party empowered to choose which transactions enter a commit or in what order. The canonical order emerges deterministically from the DAG; no committee member can selectively reorder, exclude, or front-run.
The combination eliminates the structural conditions for sandwich attacks, front-running, and proposer extraction. MEV is not policed or auctioned — it is structurally impossible at the protocol layer.
Encryption is opt-in. Simple transfers go plaintext for lower gas; MEV-sensitive operations opt in via pyde_sendRawEncryptedTransaction. Encryption adds ~ 200 ms to end-to-end latency (the threshold decryption ceremony).
10. Network Protocol
- Transport: QUIC over UDP, with TCP fallback. No head-of-line blocking, built-in TLS 1.3.
- P2P library: libp2p (Rust). Audited, used by Ethereum / Filecoin / Polkadot.
- Node identity: Ed25519 keypair (separate from validator FALCON key, rotatable).
- Peer discovery: layered (hardcoded seeds → DNS → on-chain validator registry → PEX → persistent cache). No DHT.
- Gossip: Gossipsub with per-topic meshes (
vertices,batches,decryption_shares,state_root_sigs,mempool,state_sync). Message size limits per type, enforced at parse time. - DoS defense: four layers — connection (IP / ASN caps), message (rate limits per type), peer scoring (misbehavior accumulates, decays with good behavior), application (gas tank prepayment for encrypted submission).
- Committee defense: sentry-node pattern (Cosmos-style) to insulate committee primaries from direct internet exposure.
Committee NIC requirement at v1's honest throughput target (to be established by the multi-region performance harness) is ≥500 Mbps. Higher-throughput regimes (1 Gbps, 10 Gbps) appear in §12.1 below labeled as Stretch / Aspirational, not v1.
11. Cross-Chain: The Parachain Layer (Post-Mainnet)
Cross-chain interactions in Pyde — calling functions on other chains, querying oracles, requesting off-chain compute, indexing on-chain data — happen through a parachain layer of permissionless decentralized infrastructure providers. A parachain is not a sovereign app-chain. It is an open-source implementation of a Pyde-published specification, run by operators who stake PYDE, follow protocol-defined rules, and earn gas fees from contracts that call them.
11.1 The cross_call! Macro
#![allow(unused)] fn main() { cross_call!( target_chain = "ethereum", contract = "0x...", function = "balanceOf", args = [...], callback = "handle_balance_response", ); }
The macro is asynchronous. The originating transaction marks the call pending and emits an event; the actual cross-chain or oracle work happens off-chain at the parachain operator set; the result arrives in a separate callback transaction.
11.2 HardFinalityCert
A FALCON quorum certificate over (wave_id, blake3_state_root, poseidon2_state_root), signed by ≥ 85 of the active committee. Verification on any counterparty chain: 85 FALCON-512 verifies (~ 85 ms) plus a Merkle path — feasible on any chain with a reasonable VM. The cert's stability across the chain's lifetime is what makes parachains feasible without further protocol changes after mainnet.
11.3 Architecture vs Implementation
The protocol-level surface (the cross_call! macro, HardFinalityCert, unified gas model) is settled at genesis. The actual parachain layer — specification, reference implementations, operator economics, bridges to Ethereum / Cosmos / Solana — ships post-mainnet. The mainnet cross_call! initially returns a runtime "not yet supported"; contracts written today work without rewriting when parachains activate.
11.4 Why the Parachain Framework Is the Most Consequential Adoption Surface
The parachain framework is the chain's most consequential decision for ecosystem growth: third-party developer teams launch their own execution environments — custom VMs, confidential-vote chains, gaming-specific subchains, oracle networks, privacy-focused application chains — without an auction or foundation shortlist, while inheriting Pyde's security, sub-second finality, and HardFinalityCert-based composability for free. Each parachain bootstraps its own developer community around its specific innovation; Pyde validators stake to run third-party parachains and earn the parachain's fees. The model converts what Polkadot priced through slot auctions into a permissionless capability — and gives Pyde a structural ecosystem-growth path absent from monolithic single-execution chains. For investors, this is the line where Pyde's adoption story stops being one team's plan and starts being a many-team capability surface.
12. Performance
12.1 Honest Targets
The v1 mainnet throughput target is validated by a multi-region production-realistic harness before any number is published. Pyde publishes no forward throughput figure; latency targets and the hardware envelope, by contrast, are concrete:
| Mode | v1 | Stretch | Aspirational |
|---|---|---|---|
| Plaintext throughput (sustained, commodity) | awaiting harness | awaiting harness | awaiting harness |
| Encrypted throughput (sustained, commodity) | awaiting harness | awaiting harness | awaiting harness (GPU) |
| Median commit finality | ~ 500 ms | ~ 400 ms | ~ 300 ms |
| Committee NIC | 500 Mbps | 1 Gbps | 10 Gbps |
The published throughput figure comes only from actual harness output, under the discipline of publishing only what the harness measures under sustained, production-realistic conditions — never lab extrapolations or microbenchmark peaks.
12.2 Hardware Tiers
| Role | Hardware |
|---|---|
| Light client | Mobile / browser |
| Full node / RPC | 8c / 16 GB / 500 GB NVMe / 100 Mbps |
| Non-committee validator | 8c / 16 GB / 500 GB / 100 – 250 Mbps |
| Committee validator (v1 baseline) | 8 – 16c / 32 GB / 1 TB SSD / 500 Mbps |
| Committee validator (Stretch, post-mainnet) | 16c / 32 GB / 2 TB SSD / 1 Gbps |
| Committee validator (Aspirational, GPU-class) | 32c / 64 GB / 4 TB SSD / 10 Gbps |
The commodity-hardware promise applies layered: full nodes and validators awaiting committee selection stay on a developer workstation at every throughput level. The first committee row is the v1 hardware Pyde is sized against; the higher rows are post-mainnet scaling targets, not v1 commitments.
12.3 Methodology
Pyde's pre-pivot in-house HotStuff implementation measured ~ 4 K TPS in practice — well below the original 12,500 TPS design target it claimed. The lesson: lab benchmarks ≠ production. Pyde's performance discipline going forward:
- Multi-region testing mandatory. Localhost devnet numbers do not count.
- Production-realistic workload mix. Not synthetic transfer-only; realistic ratios of transfers / AMM swaps / NFT mints / contract calls.
- Continuous soak testing. 4-hour minimum for any TPS claim that ships externally.
- Measured-only rule. External claims publish only what the harness measures under sustained, production-realistic conditions — never lab extrapolations or microbenchmark peaks.
- Public dashboard. Rolling 30-day metrics, visible.
No TPS claim is published externally without harness evidence. This is non-negotiable, and it is the most important lesson absorbed from the pre-pivot reset.
12.4 What the Numbers Enable
The v1 throughput target at ~500 ms median finality on commodity hardware is sized to run a serious DEX, a settlement system, a high-frequency NFT marketplace, a payments rail, or a real-time gaming backend — without queueing or fee spikes during peak load. Application teams designing for Pyde plan against the harness-validated number, not an aspirational one; the chain's discipline is to publish what it can deliver and over-deliver as the harness validates higher tiers. For businesses sizing Pyde for production load, the contract is honest: the v1 figure is whatever the production-realistic harness has shown, not a marketing extrapolation.
13. Economics
13.1 Token
- Total genesis supply: 1,000,000,000 PYDE
- Decimals: 9 (1 PYDE = 10⁹ quanta)
- Inflation schedule: 5 % year 1, decreasing to 3 % / 2 % / 1 %, fixed at 1 % thereafter
13.2 Validator Bonds
| Tier | Minimum stake | Role |
|---|---|---|
| Validator | 10,000 PYDE (single tier) | Eligible for uniform-random committee selection; 128 of the pool serve each epoch |
Anti-Sybil cap: max 3 validators per operator (identity-bound). Bonding: 1 epoch before active. Unbonding: 30 days (must exceed the 21-day safety-evidence freshness window). Slashing applies during both bonded and unbonding states — preventing attack-then-exit.
13.3 Fee Model
EIP-1559 base fee with elastic 4 × blocks; no priority tips. Priority would re-introduce the information asymmetry the encrypted mempool eliminates, so it is structurally excluded rather than zeroed by policy. Every transaction pays exactly gas_used × base_fee — wallets quote a single number, not a range.
Each transaction's base fee splits deterministically:
- 70 % burned (deflationary pressure)
- 10 % to treasury (multisig-controlled, PIP-reviewed)
- 20 % to the reward pool, distributed at epoch end:
- 70 % of the pool, activity-weighted across the active committee (vertices certified, batches included, decryption shares submitted, anchor selections)
- 30 % of the pool, flat across all staked validators (active-committee + awaiting-selection), distributed by stake × uptime
13.4 Indicative APY
Per-token yield is uniform across all validators (single tier; rewards distribute by stake × uptime). The activity-weighted committee bonus is layered on top during the ~3-hr epoch a validator is on the active committee. Year-1 yields are high while the validator pool is small and inflation is at the 5 % rate; the rate compresses as the pool grows and inflation tapers to the 1 % terminal floor.
13.5 Net Inflation
Net inflation = mint − burn. At sustained moderate usage (with realistic fee loads), the annual burn exceeds annual mint within a few years; the chain becomes net deflationary. At low usage, slight inflation maintains the validator security budget. At very high usage, deflationary pressure may eventually require parameter adjustment via governance.
13.6 Token Demand Drivers
PYDE has multiple protocol-internal demand drivers, not solely secondary-market speculation: validators stake PYDE to be eligible for committee selection (the active 128 are uniform-randomly selected from the eligible pool each epoch); parachain operators stake PYDE to run third-party parachains and earn the parachain's fees; every on-chain transaction (transfer, contract call, deploy, governance) pays base fees in PYDE; the treasury operates in PYDE. The fee structure (70% burned, 30% accrued to the validator reward pool + treasury) couples token value to chain usage rather than to trading volume — every base-fee unit either reduces supply or accrues to participants who secure or govern the network. For investors, the design intent is that long-term token utility tracks the chain's usefulness as infrastructure, not its position in a market cycle.
14. Governance
Pyde's governance is off-chain. Protocol changes proceed via Pyde Improvement Proposals (PIPs) — public, versioned, ratified by social consensus, modeled on Bitcoin's BIPs and Ethereum's EIPs. Validators upgrade voluntarily; hard forks happen by social agreement; the chain that retains 67 % + stake is the legitimate continuation.
On-chain governance is restricted to two surfaces: treasury spending and emergency operations, both gated by an M-of-N FALCON multisig (7-of-12 recommended) with a 30-day-bounded emergency-pause primitive.
Two-chamber on-chain governance was evaluated and explicitly rejected. Protocol upgrade should require coordinated human decision, not stake-weighted voting that incumbents can capture. The pattern of governance attacks across stake-weighted systems is the empirical case against this design. For institutional adopters, this means protocol evolution proceeds through public coordination — not silent stake-weighted votes that incumbents can capture — and the on-chain governance surface (treasury, emergency pause) is bounded, auditable, and recoverable.
15. Slashing
Pyde's slashing magnitudes are industry-aligned, with correlated-offense multipliers and an evidence-submitter reward for safety violations:
| Offense | First instance | Max (correlation / repeat) |
|---|---|---|
| Equivocation | 10 % | 50 % |
| Bad state-root signature | 10 % | 50 % |
| Bad anchor attestation | 5 % | 20 % |
| Invalid vertex | 5 % | 30 % |
| Bad decryption share | 5 % | 30 % |
| DKG failure | 2 % | 10 % |
| Share withholding (per round) | 0.1 % | 5 % / epoch |
| Extended downtime (per round) | 0.05 % | 10 % / epoch |
| Bad batch attestation | 2 % | 5 % |
Coordinated safety offenses apply a 2 × multiplier. Reporter receives 10 % of safety-slash distributions; the remainder is burned. A 24-hour slashing escrow window allows governance to void false positives. Jail escalation runs 24 h → 7 d → permanent on repeat liveness offenses.
16. Comparison to Other L1s
16.1 Comparison Matrix
| Axis | Pyde | Ethereum (L1) | Solana | Aptos | Sui | Polkadot | Cosmos | Avalanche |
|---|---|---|---|---|---|---|---|---|
| Post-quantum signatures (default) | Yes (FALCON-512) | Planned | Planned | Planned | Planned | Planned | Planned | Planned |
| Encrypted mempool (default) | Yes (Kyber-768 threshold) | No (PBS auction) | No (Jito auction) | No | No | No | Proposals in IBC track | No |
| Sandwich-attack prevention | Structural | Partial (PBS) | Partial (Jito) | Partial | Partial | N/A (relay-chain) | Partial | Partial |
| Hard-finality time | ~ 500 ms (DAG commit) | ~ 12 min | Probabilistic (~ 13 s) | < 1 s | < 1 s | ~ 12 – 60 s | ~ 6 s | ~ 1 s |
| Validator hardware | 8c / 16 GB / 500 GB / 100 Mbps (awaiting committee) | Modest | 12 + cores / 256 + GB | Modest | Modest | Modest (validator tier) | Modest (per zone) | Modest |
| Equal validator voting | Yes (1 = 1) | Stake-weighted | Stake-weighted | Stake-weighted | Stake-weighted | Stake-weighted | Stake-weighted | Stake-weighted |
| Permissionless cross-chain infra layer | Planned (parachain spec, PYDE-staked, unified gas) | L2s (per-L2 sequencer); third-party oracles | Third-party (Pyth / Switchboard) | No | No | App-chains via auctions | IBC zone-to-zone (no integrated infra) | Subnet model (sovereign, not infra) |
Each chain in this matrix is competently engineered by serious teams. The differences are choices, not capability gaps. The matrix exists to make the choices visible, not to imply a ranking.
16.2 What Pyde Owes the Field
Pyde does not invent every wheel. The chain stands on a foundation the rest of the industry built — and the strategic claim is not that other chains are wrong, but that the time has come to integrate the field's best ideas into a single greenfield design.
- Bitcoin invented the field. Public chain, hard rules, minimal trust assumptions — the social model everything in this document presupposes.
- Ethereum invented the programmable blockchain and shaped most of the design vocabulary the field still uses: smart contracts, EVM execution semantics, the EIP process, EIP-1559, account abstraction, the entire MEV literature. Pyde adopts the EIP-1559 base-fee + elastic-block design (with the priority-tip removal the encrypted mempool enables) and the EIP-style off-chain governance workflow.
- Solana proved at scale that a monolithic-binary L1 with parallel execution can deliver retail throughput, and that consensus and execution sharing one process is operationally viable. Pyde's monolithic architecture, access-list-driven scheduler, and sub-second-finality commitment are the same family of design choices Solana legitimized in production. Solana's stability work — mempool overload mitigations, consensus liveness fixes, gossipsub tuning — is the production reference for what hardening a high-throughput chain looks like.
- Aptos contributed the Jellyfish Merkle Tree that Pyde adopts as its state structure, and Block-STM as the optimistic parallel execution model Pyde adopts uniformly at v1. Aptos's measured production numbers under Block-STM (10-30K real-world TPS sustained) are the proof point Pyde's v1 throughput target is anchored against.
- Sui introduced the object-centric model as one of the cleanest expressions of ownership encoded in the transaction structure. Pyde's scheduler operates against declared access lists rather than encoded ownership, but Sui's work established that parallelism is a function of transaction format, not just scheduler implementation. Mysticeti — the consensus Pyde adopts post-pivot — was developed by the Mysten Labs team behind Sui.
- Narwhal and Bullshark (Sonnino, Spiegelman, et al.) established the worker / primary split and the DAG-based mempool design Pyde's consensus directly builds on.
- Polkadot pioneered pluggable consensus (BABE / GRANDPA) and parachain architecture as a first-class concept. Pyde's parachain layer applies pluggable-consensus thinking to a different scope — decentralized infrastructure providers, not sovereign app-chains.
- Cosmos built IBC, the most rigorous cross-chain protocol shipped to date, and the principle that cross-chain interaction should be cryptographically verifiable rather than custodially trusted. Pyde's
HardFinalityCert-based bridge primitive sits in IBC's intellectual lineage. - Avalanche demonstrated that subnet-style horizontal scaling is operationally tractable and that Snowman / Avalanche consensus can deliver sub-second finality at production scale.
- Chainlink built the production reference for decentralized oracle networks — the operator-set staking model, deviation-tolerance attestations, off-chain-to-on-chain data bridges. Pyde's parachain layer is Chainlink-style decentralized infrastructure integrated natively into an L1's gas model.
- Filecoin and the libp2p / IPFS ecosystem produced the modular networking stack Pyde uses as transport. Pyde's net-layer crate is integration work over a stack the field built.
- Cardano, Tezos, Algorand, Mina, Aleo, Diem, the Move language team, the entire ZK-rollup research community, Flashbots, the Rust async-runtime ecosystem, NIST, IETF — all shaped the design space. The list is not exhaustive.
Where Pyde diverges — post-quantum-from-genesis, encrypted-mempool-by-default, equal voting, commodity hardware, the permissionless parachain layer with unified gas — is where the bet sits. Every chain in the comparison was built for the era it was built for. Pyde is the only chain in the table that needs no migration to ship all four properties.
For investors and adoption partners evaluating which L1 to build on, the practical question is not which chain is fastest in a benchmark but which chain's properties will still match the application's needs in 2030 and 2035. Pyde's bet is that the answer is the chain that started with those properties — quantum-resistant, MEV-resistant at the protocol layer, sub-second-final, commodity-validated, permissionlessly extensible — rather than the chain that has the longest migration to do them.
17. Open Problems
The design is complete in the senses that matter; the engineering risk is concentrated in a few specific places that this document calls out explicitly rather than hides.
17.1 Threshold Post-Quantum Cryptography
Production-grade threshold variants of Kyber are research-stage. Pyde v1 may ship with a classical-crypto threshold scheme (ElGamal-style over a high-prime field, used only inside the threshold-decryption ceremony) as a transitional measure, migrating to threshold Kyber when audited implementations mature. This is the single largest cryptographic engineering risk in the design. It is being actively researched, not skipped.
17.2 Batch Threshold Decryption
Per-ciphertext threshold decryption scales poorly at very high encrypted throughput — beyond a certain point the per-ceremony cost dominates on commodity hardware. Batch decryption schemes — where one threshold ceremony decrypts multiple ciphertexts amortized — are research-stage. Pyde v2 will adopt one once standardization matures; v1 ships the per-ceremony scheme with GPU-acceleration headroom characterized by the performance harness.
17.3 ZK Light Clients
The hybrid-hashing strategy (Poseidon2 on ZK-bearing paths) keeps zero-knowledge proof options open. Post-mainnet, ZK-validated state proofs would enable succinct light clients (kilobytes of proof, full security). Specific SNARK system choice (Plonky3, SP1, Halo2, RISC Zero) is deferred until the consensus rebuild lands and the per-circuit cost can be measured against real protocol structures.
17.4 Programmable Accounts and Session Keys
Native multisig ships at v1. Programmable accounts (sandboxed WASM policy modules expressing spend limits, time locks, allow-listed recipients, tiered authorization, recovery flows) and session keys (epoch-bounded, scope-limited dApp delegation without per-action wallet popups) ship post-mainnet.
A session key is bounded by four parameters: an allow-list of contracts, an optional allow-list of method selectors, a hard spend cap, and an expiry wave. Each authorization checks the FALCON signature, the liveness flags, the scope, and the cumulative spend — all four must pass. Revocation is a single tx signed by the account's main auth_keys and takes effect at the next wave commit. Ethereum is retrofitting the same idea via ERC-4337; Pyde gets it at the protocol layer.
The AuthKeys enum reserves the Programmable variant (tag 0x03) at genesis. The account code_hash + storage_root shape and the multisig signature pipeline are also v1 surfaces that v2 reuses, so contracts written today survive the upgrade without rewriting. The full mechanism is documented in Chapter 11 Session keys (v2) and companion/DESIGN.md.
17.5 Parachain Layer
The protocol-level cross-chain primitives (cross_call!, HardFinalityCert) ship at genesis with mainnet stubs. The full parachain layer — specification, reference implementations, operator economics, bridges to Ethereum / Cosmos / Solana — ships post-mainnet.
18. Path to Mainnet
This document is the technical specification of the post-pivot design. The engineering between specification and mainnet is the work ahead, in execution order:
- Mysticeti DAG implementation. Adapt the open-source Mysticeti reference for FALCON-bound signatures and Pyde's threshold-decryption integration; rebuild the consensus, mempool, and node crates against the new foundation.
- Performance harness build-out. Multi-region production-realistic infrastructure; workload generators for the four target tx-mixes; chaos / failure injection; soak-test schedule. Pre-mainnet test slate is mandatory before any external TPS claim.
- External audit programme. Multi-track, specialist firms across consensus, the WASM execution layer integration (host-function ABI, fuel-to-gas mapping, deploy-time validator), post-quantum cryptography, networking, and the
otigendeveloper toolchain. Remediate all critical and high findings; re-audit the remediation. The wasmtime runtime itself is a vetted production dependency from the Bytecode Alliance and is not separately audited. - Incentivized testnet. Reference dApps (DEX, lending market, NFT marketplace); fully-funded bug bounty at mainnet-tier scale; a multi-month soak test; remediate community-found issues before launch.
- 128-validator genesis. Recruit operators with documented hardware benchmarks and incentivized-testnet participation. Geo-distribute across 3 + regions. Coordinate validator DKG for the threshold pubkey. Sign the genesis block. Publish the chain hash.
There is no public schedule. Mainnet ships when the audit programme passes and the incentivized testnet validates the throughput target on production-realistic infrastructure — not before. For investors, the absence of a public schedule is by design: the project prioritizes correctness over a date, and each milestone above is a gate that must close on the merits before mainnet.
19. Conclusion
Pyde represents a chain built around the architectural requirements of the next decade: post-quantum security, MEV resistance, sub-second finality, and commodity-hardware decentralization for users and infrastructure. The pivot from in-house HotStuff to Mysticeti-style DAG consensus reflects an explicit commitment to designing from a clean foundation rather than patching accumulated technical debt.
The design is complete; the implementation is the work ahead. This is not a chain that ships in six months. It is a chain that aims to occupy a category — post-quantum, MEV-resistant, commodity-validated — that no production chain occupies cleanly today. The strategic window for that occupancy is open and time-bound.
For businesses, that category means settlement infrastructure that holds through the next cryptographic generation, without a hidden tax on customer transactions and without a coordinated migration to budget for. For developer teams, it means a permissionless surface to launch their own execution environments and bootstrap dev communities around them, on top of a runtime they already know how to write. For users, it means trades that execute at the price signed and funds that remain valid through whatever the next decade brings cryptographically. The architecture is the bet; the implementation work is what turns the bet into a chain people can actually use.
Document version: 0.2
Status: Living document
License: Apache-2.0 — see LICENSE at the repository root
Pyde Validator Lifecycle
Version 0.1
This document specifies the validator state machine, operations, parameters, and anti-Sybil mechanisms.
State Machine
[NOT REGISTERED]
↓ register_validator(stake ≥ MIN_VALIDATOR_STAKE, falcon_pubkey, threshold_key)
↓ (single tier; MIN_VALIDATOR_STAKE = 10,000 PYDE)
[PENDING ACTIVATION] (1 epoch bonding period)
↓ next epoch boundary
[ACTIVE - WAITING] ←──────┐
↓ VRF selects │ epoch ends (not re-selected)
[COMMITTEE - ACTIVE] ──────┘
↓ request_unbond()
[UNBONDING] (30 days)
↓ 30 days elapsed
[WITHDRAWABLE]
↓ withdraw()
[NOT REGISTERED]
Side states (from any active state):
→ [SLASHED] (stake reduced; forced unbond if < min stake)
→ [JAILED] (excluded from committee; unjail required)
Parameters
| Parameter | Value | Notes |
|---|---|---|
MIN_VALIDATOR_STAKE | 10,000 PYDE | Single-tier minimum; any validator meeting this threshold enters the eligible pool for uniform-random committee selection |
MAX_VALIDATORS_PER_OPERATOR (cap) | 3 | Anti-Sybil; enforced on operator identity, not stake |
BONDING_PERIOD | 1 epoch (~3 hours) | Time from registration to active eligibility |
UNBONDING_PERIOD | 30 days | Long enough for safety evidence to surface |
EVIDENCE_FRESHNESS_SAFETY | 21 days | Must be < unbonding period |
EVIDENCE_FRESHNESS_LIVENESS | 1 epoch | Real-time only |
KEY_ROTATION_INTERVAL | Max once per epoch | Prevents rotation abuse |
JAIL_PERIOD_1ST | 24 hours | First jail |
JAIL_PERIOD_2ND | 7 days | Within 30 days of first |
JAIL_3RD | Permanent | 3rd jail = permanent removal |
UNJAIL_FEE | 10 PYDE | Anti-griefing |
SLASHING_ESCROW | 24 hours | Dispute window before slash finalizes |
NEW_VALIDATOR_GRACE_EPOCHS | 1 | 50% reduced slashing in first epoch |
Pseudocode convention. Where this document writes
MIN_STAKEin pseudocode below, it refers toMIN_VALIDATOR_STAKE(10,000 PYDE) — the single-tier minimum.
State Details
[NOT REGISTERED]
Default state. Account is a user wallet, not a validator.
[PENDING ACTIVATION]
Registered with stake, waiting to become eligible.
- Triggered by:
register_validator(stake, falcon_pubkey, threshold_verify_key, operator_identity) - Stake is locked
- Earns nothing during pending
- Auto-transitions to ACTIVE-WAITING at next epoch boundary
[ACTIVE - WAITING]
In the pool, eligible for VRF selection into committee.
- Conditions: stake ≥ MIN_STAKE AND not jailed AND grace period passed
- Earns: flat 30% pool yield (proportional to stake)
- Selected randomly for committee at each epoch boundary
- Cannot be slashed for liveness (no committee duties)
- Can still be slashed for safety (e.g., late-submitted equivocation evidence)
[COMMITTEE - ACTIVE]
Selected for current epoch as one of 128 active members.
- Duties: vertex production, decryption shares, DKG participation, state-root signing
- Earns: activity-weighted share of 70% committee pool + flat 30% pool yield + inflation share
- Subject to full slashing (safety + liveness)
- Loops back to ACTIVE-WAITING at next epoch boundary unless re-selected
[UNBONDING]
Exiting voluntarily.
- Triggered by:
request_unbond() - Stake locked for 30 days
- Cannot be selected for committee
- Cannot earn rewards
- Can still be slashed for offenses within freshness window
- Auto-transitions to WITHDRAWABLE after 30 days
[WITHDRAWABLE]
Stake unlocked, claim available.
- Triggered after 30-day unbonding completes
- User calls
withdraw()to claim remaining stake (after any slashing) - Transitions to NOT REGISTERED
- Frees operator slot for new validator registration
[SLASHED] (Modifier)
- Stake reduced by slash amount
- If remaining stake < MIN_STAKE → forced unbonding
- 24-hour slashing escrow before distribution applied
- See SLASHING.md for full slashing details
[JAILED] (Modifier)
- Excluded from committee at next epoch boundary
- Cannot be selected during jail period
- Stake still locked (not unbonding)
- Requires
unjail()transaction to rejoin pool - Escalates: 24h → 7d → permanent
Operations
Register Validator
#![allow(unused)] fn main() { fn register_validator( stake: u64, falcon_pubkey: FalconPubkey, threshold_verify_key: ThresholdVerifyKey, operator_identity: Address, // anti-Sybil binding ) -> ValidatorId // Preconditions: // - stake >= MIN_STAKE // - operator_identity has < MAX_VALIDATORS_PER_OPERATOR validators // - sender has sufficient balance // // Effects: // - Transfer stake to bonded escrow // - Set state = PENDING_ACTIVATION // - Activation epoch = current_epoch + 1 // - Emit ValidatorRegistered event }
Request Unbond
#![allow(unused)] fn main() { fn request_unbond(validator_id: ValidatorId) -> UnbondingClaim // Preconditions: // - Caller is validator's stake account // - State is ACTIVE-WAITING or COMMITTEE-ACTIVE // - If COMMITTEE-ACTIVE: complete current epoch first // // Effects: // - Set state = UNBONDING // - withdrawable_at = current_time + UNBONDING_PERIOD // - Emit ValidatorUnbonding event }
Withdraw
#![allow(unused)] fn main() { fn withdraw(validator_id: ValidatorId) -> u64 // Preconditions: // - Caller is validator's stake account // - State is WITHDRAWABLE // - No unresolved slashing escrow // // Effects: // - Compute remaining stake (after any slashing) // - Transfer to operator account // - Set state = NOT_REGISTERED // - Free up operator slot // - Emit ValidatorWithdrawn event }
Rotate Keys
#![allow(unused)] fn main() { fn rotate_keys( validator_id: ValidatorId, new_falcon_pubkey: FalconPubkey, new_threshold_verify_key: ThresholdVerifyKey, ) -> Result // Preconditions: // - Caller is validator's stake account // - Last rotation > KEY_ROTATION_INTERVAL ago // - State is ACTIVE-WAITING (not in committee — disruption risk) // // Effects: // - Update pubkeys in account state // - Effective at next epoch boundary // - Old pubkey kept for VERIFY ONLY during 1-epoch grace // - Emit KeyRotated event }
Unjail
#![allow(unused)] fn main() { fn unjail(validator_id: ValidatorId) -> Result // Preconditions: // - State is JAILED // - Time since jail >= jail_period_for_this_offense // - Pays UNJAIL_FEE // - Remaining stake >= MIN_STAKE // - Not 3rd jail (permanent) // // Effects: // - Set state = ACTIVE-WAITING // - Eligible for next committee selection // - Emit ValidatorUnjailed event }
Anti-Sybil: Multiple Validators per Operator
Identity binding via operator_identity field:
- Default: same address as stake account (1:1 binding)
- Optional: multiple validators per operator if registered under same identity
- Cap:
MAX_VALIDATORS_PER_OPERATOR = 3
Why Cap?
- Sybil amplification: without a cap, a rich operator could run dozens of validators under different keys and dominate committee selection
- Cap forces multi-operator diversity — a 43-Byzantine fork requires ≥ 15 distinct KYC'd operator identities
- 3 still allows operational diversity (HA pair + standby, or three-region geographic distribution)
Optional Stronger Anti-Sybil (Post-Mainnet PIP)
Escalating bond for additional validators registered under the same operator identity:
| Validator slot | Required stake |
|---|---|
| 1st | 10,000 PYDE |
| 2nd | 10,000 PYDE |
| 3rd | 20,000 PYDE |
Reduces ROI on heavy concentration. Tracked as post-mainnet hardening; not in scope for v1.
Committee Selection (Each Epoch)
# At end of epoch N, derive committee for epoch N+1:
eligible = [v for v in all_validators if v.stake >= MIN_STAKE
and not v.jailed
and v.grace_period_passed]
for slot in 0..128:
seed = Hash(beacon || slot)
member = uniform_random_pick(eligible, seed)
committee[slot] = member
eligible.remove(member) # without replacement
Selection is uniform random within eligible pool. Stake influences only:
- Probability of being eligible (must meet MIN_STAKE)
- Proportion of flat 30% stake-pool yield
Stake does NOT influence committee selection probability. Equal probability among eligible validators.
Edge Cases
1. Slashed below MIN_STAKE
- Validator forced into UNBONDING state
- 30-day countdown starts
- Cannot be re-selected during unbonding
- After unbonding, can re-register with fresh stake
2. Operator wants more validators
- Register new validator under same
operator_identity - Allowed up to
MAX_VALIDATORS_PER_OPERATOR - Each requires separate
MIN_STAKE
3. Mid-Epoch Hardware Upgrade
- Key rotation requires ACTIVE-WAITING state
- P2P endpoint updates allowed any time (cosmetic)
- For key compromise: emergency rotation allowed any time (with higher fee + audit)
4. Operator Goes Bankrupt / Disappears
- Accumulates downtime slashing over ~3 epochs
- Eventually slashed below MIN_STAKE → forced unbond
- 30-day timer starts
- Stake withdrawable by operator's stake account after 30 days
- No "abandoned validator" cleanup needed; lifecycle handles it
References
- Slashing details: see SLASHING.md
- Committee selection (full algorithm): see WHITEPAPER.md §5.5
- Network protocol (peer addresses): see NETWORK_PROTOCOL.md
Document version: 0.1
License: See repository root
Pyde Slashing Rules
Version 0.1
This document specifies all slashable offenses, detection mechanisms, slash amounts, evidence flow, jail mechanics, and the interaction with the validator lifecycle.
Numbers below are starting points. Final numbers require economic modeling pre-mainnet and may be adjusted via PIP.
Principles
- Safety vs Liveness distinction — different severity, detection, and slash amounts
- Correlated slashing for safety — coordinated attacks lose more
- Permissionless evidence — anyone can submit cryptographic evidence; reporter reward incentivizes monitoring
- Bounded slashing — per-epoch caps prevent stacking attacks
Offense Catalog
Safety Offenses (Severe, Cryptographic Evidence)
| # | Offense | First instance | Max (correlation/repeat) | Jail | Distribution |
|---|---|---|---|---|---|
| 1 | Equivocation (vertex) — two different vertices for same (round, member_id) | 10% | 50% | 1 epoch | 50% burn / 30% treasury / 20% reporter |
| 2 | Bad state-root signature — two contradictory state roots for same commit | 10% | 50% | 1 epoch | Same as above |
| 3 | Bad anchor attestation — vertex's prev_anchor_attestation contradicts 85+ honest majority | 5% | 20% | 1 epoch | Same as above |
| 4 | Invalid vertex structure — parent refs out of order, refs to non-existent batches | 5% | 30% | 1 epoch | 100% burn |
| 5 | Bad decryption share — partial that provably doesn't combine correctly | 5% | 30% | 1 epoch | 50% burn / 30% treasury / 20% reporter |
Liveness Offenses (Auto-Detected, Graduated)
| # | Offense | Per-event | Per-epoch cap | Jail | Distribution |
|---|---|---|---|---|---|
| 6 | DKG participation failure — invalid or missing shares during DKG | 2% | 10% | Until next epoch | 100% burn |
| 7 | Share withholding — no decryption share when expected | 0.1%/round missed | 5%/epoch | After 100 consecutive missed | 100% burn |
| 8 | Extended downtime — no vertices produced for N consecutive rounds | 0.05%/round | 10%/epoch | After 5% reached | 100% burn |
| 9 | Bad batch attestation — worker gossips batch with invalid txs | 2% | 5% | None (warning) | 100% burn |
Future / Deferred
| # | Offense | Status |
|---|---|---|
| 10 | Censorship (provable, off-chain coordination) | v2 (requires cryptographic censorship commitments) |
Correlation Multiplier (Safety Offenses Only)
To punish coordinated attacks and protect isolated failures:
correlation_multiplier = 1 + (other_offenders_this_epoch / max_byzantine)
= 1 + (k / 42) for n=128
Caps at 2× to avoid disproportionate punishment in bug scenarios.
| Other offenders | Multiplier | Effective slash (equivocation 10%) |
|---|---|---|
| 1 | 1.02× | 10.2% |
| 10 | 1.24× | 12.4% |
| 42 (max byzantine) | 2.0× | 20% |
| 43+ | 2.0× (cap) | 20% |
Combined with repeat-offense escalation, a coordinated 43-attack can hit the maximum 50% slash within an epoch.
Slash Math (Percentages of Offender's Stake)
All percentages apply to the offender's current stake at the time of offense. Pyde uses a single staking tier:
- Validators: minimum
MIN_VALIDATOR_STAKE= 10,000 PYDE - Operator-identity cap: 3 validators per operator (anti-Sybil)
- Real-world bonds will be higher than the minimum (rational operators stake more to absorb minor liveness penalties without falling below the floor)
Equivocation (10% × correlation × repeat) — minimum 10K PYDE bond:
1st instance, alone: 1,000 PYDE
1st instance, 42 others: 2,000 PYDE (2× correlation cap)
2nd instance, 42 others: 4,000 PYDE
Capped at 50%: 5,000 PYDE (full burn at the bond floor)
Downtime (0.05%/round) — minimum 10K PYDE bond, when serving on the active committee:
10 rounds missed: 5 PYDE
100 rounds missed: 50 PYDE (5% — also triggers jail)
At 10% epoch cap: 1,000 PYDE
Liveness penalties apply only while a validator is on the active committee for the epoch. Validators awaiting selection have no per-round liveness obligation (they can still be slashed for safety offenses with freshness-window evidence).
Evidence Submission
Permissionless: any node can submit evidence.
#![allow(unused)] fn main() { struct Evidence { offense_type: OffenseType, offender_id: ValidatorId, epoch: u64, proof: CryptographicProof, reporter_id: Option<Address>, // for reward distribution } // Submission as a regular transaction (paid gas) fn submit_evidence(evidence: Evidence) -> Result<()> { // Engine verifies cryptographic proof // If valid: // - Stake slashed from offender (subject to 24h escrow) // - Distribution applied (burn / treasury / reporter) // - Jail status set if applicable // - Event emitted for indexing } }
Evidence Freshness Window
- Safety offenses: 21 days
- Liveness offenses: 1 epoch (real-time only)
- DKG failures: 1 epoch (same as ceremony)
Outside the window: cannot slash. Evidence becomes historical record but no enforcement.
Reporter Cooldown
- Same reporter address: max 5 evidence transactions per epoch
- Limits griefing (malicious reporter spamming invalid evidence)
Jail Mechanics
When a validator is jailed:
- Removed from committee at next epoch boundary
- Cannot rejoin until
unjail()transaction executed - Unjail requirements:
- Time elapsed ≥ jail period
- Pays unjail fee (10 PYDE — anti-griefing)
- Remaining stake ≥ minimum bond for the validator's tier (MIN_VALIDATOR_STAKE = 10K PYDE — single tier)
Escalating Jail Periods
- 1st jail: 24 hours
- 2nd jail within 30 days: 7 days
- 3rd jail: permanent removal (kicked out of validator set)
Slashing Escrow (24-Hour Dispute Window)
To handle false-positive slashes:
Stake state machine:
bonded → slashed_frozen → slashed_finalized
(24h)
During the 24-hour escrow:
- Slashed stake is in "frozen" state (not yet destroyed)
- Governance multisig can void or reduce the slash
- After 24h with no dispute: slash finalizes (distribution applied)
This protects against bugs in slashing logic or contested circumstances (e.g., network partition that fooled detection).
New Validator Grace Period
A validator in their first epoch has 50% reduced slashing on all offenses. Encourages experimentation with new operational setups; bad actors can't hide forever (just one epoch).
Unbonding Interaction
Critical: unbonding must exceed evidence freshness to prevent attack-then-exit.
Unbonding period: 30 days
Safety evidence freshness: 21 days
30 > 21 → prevents attacker withdrawing before evidence is submitted
State machine:
bonded → (request_unbond) → unbonding (30d) → withdrawable
↓
still slashable during unbonding
Slashing applies during BOTH bonded and unbonding states. After withdrawal (past 30 days): cannot slash.
Edge Cases
1. Network Partition
If >43 validators go offline simultaneously due to network split:
- Downtime slashing PAUSES (auto-detected by protocol — committee active count < 85 → liveness mode)
- Resumes once active count ≥ 85
- Prevents punishing the 85+ honest majority while 43+ are partitioned
2. Key Compromise
Validator's key stolen, attacker double-signs:
- Slashing applies (your responsibility as key holder)
- Mitigations: HSM, key rotation, multisig validators (v2)
- No insurance pool (avoid moral hazard)
3. Chain Halt
If chain halts entirely:
- No automatic slashing during halt
- Manual investigation post-recovery
- Specific validators slashed only with cryptographic evidence
4. Hard Fork
If chain hard-forks:
- Slashing state migrates with the chain
- "Wrong-fork" validators on minority chain don't auto-slash (separate chains, separate state)
Sanity Check
At the bond floor, total committee bond: 128 × 10K = 1.28M PYDE. In practice operators stake more to absorb minor penalties without falling below the floor — realistic total committee bond depends on actual operator behavior post-launch.
Max single-event slash at floor (42 offenders × equivocation × 2× correlation):
42 × 10K × 10% × 2.0 = 84K PYDE (= 6.5% of total committee bond at floor)
Max correlated attack across epoch (42 offenders × 5 events × 2× correlation, capped at 50%):
42 × 10K × 50% = 210K PYDE (= 16.4% of total committee bond at floor)
These dollar numbers are intentionally not the load-bearing deterrent. Pyde's security argument (Chapter 16 §16.4) is that threshold encryption removes the attack-profit motive entirely — there is no MEV-extraction revenue to recoup. Stake serves as a credible-commitment deposit against slashable misbehavior plus the input the slashing mechanism has to slash. The operator-identity cap, KYC binding, and slashing-with- finder's-fee do the heavy lifting on Sybil resistance.
Implementation Notes
Slashing is implemented as system transactions handled by the engine:
#![allow(unused)] fn main() { // At evidence submission: engine.execute_system_tx(SystemTx::SubmitEvidence(evidence)); // At slashing escrow expiry (24h after slash): engine.execute_system_tx(SystemTx::FinalizeSlash(slash_id)); // At unjail request: engine.execute_system_tx(SystemTx::Unjail(validator_id)); }
All slashing state is part of validator account state, indexed by validator_id.
References
- Validator lifecycle: see VALIDATOR_LIFECYCLE.md
- Threat catalog (cross-reference): see THREAT_MODEL.md
- Chain halt + recovery: see CHAIN_HALT.md
Document version: 0.1 License: See repository root
Pyde State Sync Protocol
Version 0.1
How new nodes join the network at any point in time. At the chain's sustained throughput, replaying from genesis is infeasible — snapshot sync is the default.
Sync Modes
| Mode | Use Case | Time |
|---|---|---|
| Full sync (genesis replay) | Archive nodes only | Infeasible at high TPS |
| Snapshot sync (default) | Most full nodes, new committee joiners | ~30-60 min on commodity |
| Light client sync | Mobile wallets, browser, dApp backends | Seconds-minutes |
Snapshot Architecture
Key separation:
- Committee signs state root (cheap, every epoch boundary)
- Volunteers generate chunks (heavier, daily-ish cadence)
This drops committee disk I/O burden. Manifest is small and committee-signed; chunks are large and content-verifiable.
Snapshot Manifest
#![allow(unused)] fn main() { struct SnapshotManifest { epoch: u64, snapshot_state_root_blake3: Hash, snapshot_state_root_poseidon2: Hash, chunk_manifest: Vec<ChunkRef>, current_committee_pubkeys: Vec<FalconPubkey>, // chain-of-trust signatures: Vec<FalconSig>, // ≥85 from prior epoch's committee } struct ChunkRef { chunk_index: u32, chunk_size: u32, chunk_hash: Hash, // Blake3 chunk_path: String, // P2P routing hint } }
Why Dual Roots
- Blake3: fast native verification
- Poseidon2: future ZK light-client compatibility
Both computed at snapshot time, both signed by committee.
Snapshot Cadence
- Committee root signing: every epoch boundary (cheap)
- Chunk publishing: every 8 epochs (~daily) by volunteer infrastructure providers
- Tail sync window: up to 24 hours of txs to catch up
Snapshot Size Projections
| Component | v1 mainnet | 5-year projection |
|---|---|---|
| Account state (~10M accounts × ~150B) | 150 MB – 1.5 GB | 5-10 GB |
| Contract storage (~5× accounts × 64B) | 500 MB – 3 GB | 20 GB |
| Contract code (~50K contracts × 50KB) | ~2.5 GB | 20 GB |
| Total | ~1-3 GB | ~50 GB |
Chunk Format and Merkle Range Proofs
Each snapshot chunk is a self-contained, independently-verifiable bundle of JMT nodes. A chunk's authenticity is proven by walking its nodes' hashes up to the committee-signed state root, using fringe siblings carried in the chunk.
#![allow(unused)] fn main() { struct Chunk { chunk_id: u32, // Contiguous range of jmt_cf entries (internal nodes + leaves) covered by this chunk. nodes: Vec<(NodeKey, NodeContents)>, // The slot_hash → value pairs for leaves in this chunk's range. // (Used to populate state_cf at the new validator.) leaves: Vec<(SlotHash, ValueBytes)>, // Merkle range proof — the sibling hashes along the path from the chunk's // bottom layer up to the global state_root. Needed to verify the chunk // independently of other chunks. fringe_siblings: Vec<(NibblePath, Hash)>, } }
Why fringe siblings
The chunk doesn't contain the entire JMT — that would be every other chunk too. It contains some contiguous portion (e.g., "all nodes whose NibblePath starts with 3a"). To prove that portion is part of the canonical state at the snapshot's version, the chunk must include the sibling hashes along the boundary.
Conceptual example:
Suppose the JMT looks like:
ROOT
/ \
h_3 h_5
/ \ \
... ... leaf at 0x5b22...
A chunk covers leaves under "3a..." prefix. It contains:
- All internal nodes under "3a"
- All leaves under "3a"
- Fringe sibling: h_5 (sibling of h_3 at root level)
- Any other siblings along the path from the "3a" subtree to root
The chunk does NOT include leaves under "5..." prefix; only their hash on the way up.
Verification per chunk
For each chunk received:
1. For each leaf in chunk.leaves:
compute leaf_hash = Hash(slot_hash || value || metadata)
2. Reconstruct internal-node hashes within the chunk's subtree using its
internal-node entries (NodeContents include children's fingerprints).
3. Walk up from the chunk's local root using fringe_siblings at each level:
current_hash = chunk_local_root_hash
for (sibling_path, sibling_hash) in fringe_siblings:
combine_hashes(current_hash, sibling_hash, sibling_path)
4. Final hash MUST equal trusted state_root (from the committee-signed manifest).
5. If yes: chunk is authentic. Write its (NodeKey, NodeContents) pairs into
local jmt_cf, and its (slot_hash, value) pairs into local state_cf.
6. If no: discard. Request the chunk from a different peer (the source was malicious
or corrupted). The bad peer is penalized via peer scoring.
Properties
- Each chunk is independently verifiable. Lose one chunk, request from another peer; no cascading failure.
- The fringe siblings are small (~few hundred bytes per chunk) — they don't materially inflate chunk size.
- The proof is non-interactive — chunk + fringe siblings is enough; no back-and-forth needed.
- Standard cryptographic primitive — Aptos's JMT uses this; Ethereum's MPT has similar range-proof support. Not novel.
Snapshot manifest RPC handler
RPC method: pyde_getSnapshotManifest(wave_id)
→ Returns SnapshotManifest for that wave's snapshot, or NotAvailable.
Behind the scenes:
1. waves_cf.get(wave_id) → WaveCommitRecord → look up jmt version
2. snapshots_cf.get(version) → SnapshotManifest if pre-generated, else None
3. If None: optionally generate on-demand (expensive; archive only)
4. Return manifest
Snapshot generation (background, archive nodes):
- Triggered every N waves (e.g., every epoch)
- Walk jmt_cf at target version, group nodes into ~50MB chunks with key-range partitions
- Compute range proofs (fringe siblings) for each chunk
- Store chunks + manifest in snapshots_cf
- Manifest published with committee threshold sig
Verification Flow
Phase 1: Discover & Verify Manifest
1. Bootstrap from seed peers
2. Discover manifest URLs/hashes from peers
3. Download signed manifest (~5 KB)
4. Verify ≥85 FALCON sigs against trusted committee pubkeys
Phase 2: Download Chunks
5. Discover peers serving snapshot
6. Download chunks in parallel (4 MB each)
7. Verify each chunk_hash against manifest
8. Bad chunks → ban peer, retry from another
Phase 3: Reconstruct State
9. Apply chunks to JMT
10. Compute Blake3 state root locally
11. Compare to manifest.snapshot_state_root_blake3
12. If match: snapshot valid, accept
Phase 4: Recent Sync (Tail)
13. Download blocks from snapshot point to current
14. Replay txs against snapshot state
15. Reach current state, exit sync mode
Phase 5: Active Operation
16. Subscribe to gossip
17. Begin normal participation
Bootstrap from Genesis: Chain-of-Trust
A new node doesn't yet know which committee pubkeys to trust. Solved via genesis chain:
Genesis block: contains committee_0.pubkeys (hardcoded by founders)
↓
Snapshot at epoch 8: signed by committee 0, contains committee_8.pubkeys
↓
Snapshot at epoch 16: signed by committee 8, contains committee_16.pubkeys
↓
... etc forward
New node verifies the chain by:
- Downloading genesis (~5 MB, includes committee_0 pubkeys)
- Downloading intermediate manifests (~5 KB each, hundreds at scale)
- Verifying chain forward: each manifest signed by prior committee
- Accepting current snapshot if chain-of-trust holds
Weak Subjectivity Checkpoints (Optional)
For nodes that don't want full chain-of-trust verification:
- Foundation and reputable infra providers publish "trusted recent checkpoints"
- Signed by their own keys (not committee)
- Assert: "we've verified the chain up to epoch X, root = Y"
- Distributed via known infrastructure (HTTPS, signed websites)
- Updated weekly
New node options:
- Purist: full chain-of-trust from genesis (long but trustless)
- Pragmatist: trust a recent checkpoint, sync from there (fast)
Both produce same security guarantees from the trusted point forward.
Light Client Mode
Doesn't download full state. For mobile wallets, browser dApps, embedded clients.
Storage
- Block headers only (no full blocks)
- Recent committee pubkeys
- Own account state + recent transactions
- JMT proofs for accounts user cares about
Operations
- Verify new block headers via FALCON sigs (~85 verifies, ~6.8ms)
- Query specific accounts: ask full node for
{balance, JMT inclusion proof} - Verify proof against latest signed state root
- Submit transactions: same as regular RPC
Bandwidth
~600 KB/year for typical wallet usage (8 epochs/day × 365 days × ~200 bytes per epoch boundary header).
Incremental Sync (Delta Snapshots)
For nodes with a recent snapshot:
Have: Snapshot at epoch E
Want: Snapshot at epoch E + 8
Delta snapshot:
- Changed accounts since epoch E
- Changed storage slots since E
- New contracts deployed since E
- Signed by committee at E + 8
Apply delta to existing local state → updated snapshot
Saves bandwidth: typical delta is 10-50 MB vs full 3 GB.
Storage / Pruning Policy
| Node type | State retention | Block retention |
|---|---|---|
| Archive node | All historical state | All blocks since genesis |
| Full node (default) | State for last 90 days | Blocks for last 30 days |
| Committee validator | State for last 30 days | Blocks for last 8 epochs |
| Light client | Headers + cared-about accounts | Headers only |
Tunable per-node. Archive nodes earn slightly higher RPC fees for serving historical queries.
Failure Modes & Recovery
| Failure | Detection | Recovery |
|---|---|---|
| All peers serve bad data | Manifest sig fails | Try more peers, ban liars |
| Snapshot corruption mid-download | Chunk hash mismatch | Ban peer, retry chunk from another |
| Manifest signed by wrong committee | Sig verify fails | Reject manifest, find another |
| Network outage during sync | Connection dropped | Resume from last verified chunk |
| Snapshot too old (> evidence window) | Sig set might be slashed | Use newer snapshot |
Time Estimates (commodity hardware, 100 Mbps)
Bootstrap from genesis (small): ~5 seconds
Manifest verification (85 FALCON): ~7 ms
Snapshot download (3 GB at 100 Mbps): ~4 minutes
JMT reconstruction: ~5 minutes
Recent tail sync (8 epochs of txs): ~30 minutes
Total: ~40 minutes
For comparison: Ethereum snap sync 4-24 hours, Cosmos statesync 1-3 hours.
State Growth (v2 Concern)
5-year projection of ~50 GB is optimistic. Solana shows ~80 GB after 4 years despite aggressive engineering.
Future mitigations (defer to v2):
- Account expiration (Aptos pattern): accounts not touched in N years get archived
- Storage rent (Solana pattern): accounts pay rent to stay active
- Stateless validators (Ethereum research): validators use state proofs
References
- Hash strategy: see WHITEPAPER.md §4.3
- Light client (more detail): see WHITEPAPER.md §7
- Network bandwidth: see NETWORK_PROTOCOL.md
Document version: 0.1
License: See repository root
Pyde Chain Halt + Recovery Procedures
Version 0.1
The HotStuff lesson made operational: explicit halt detection → investigation → recovery procedures. No live-patching under pressure.
Three Halt Types
| Type | Trigger | Severity | Authority | Recovery |
|---|---|---|---|---|
| Soft stall | Network/quorum issues | Liveness only | Emergent (any node detects) | Wait (auto-resume) |
| Hard halt | Detected inconsistency (state root divergence, equivocation cluster) | Safety risk | Protocol-detected automatic | Manual investigation |
| Emergency halt | Critical bug, active exploit, hard-fork prep | High intentional | Governance multisig (7-of-12) | Per-incident, max 30 days |
Detection Mechanisms
Soft Stall (Automatic)
- No commit for > 5 rounds (~1s expected, so 5s threshold)
- <85 vertices certified for last K rounds
- Active committee count drops below safety threshold (86)
Response: Validators enter "stall mode" — produce vertices, wait for quorum. Mempool keeps accepting txs (queued). Auto-recover when conditions improve.
Hard Halt (Automatic)
- State root divergence detected (2+ signed contradictory roots for same commit)
- Equivocation cluster (10+ validators in single epoch)
- DKG output mismatch
- Execution layer critical invariant violation
- DAG fork detected (impossible per protocol, indicates bug)
Response: All validators stop producing vertices. All commits halted. Halt event broadcast. Forensic state preserved. Manual intervention required.
Emergency Halt (Manual)
- Critical bug discovered (off-chain, e.g., security researcher)
- Active exploit being mitigated
- Hard-fork coordination needed
- State recovery from previous incident
Response: Governance multisig signs HaltMessage with timestamp + reason. Halt activated for max 30 days (constitutional limit).
What Happens During Halt
| Activity | Soft Stall | Hard Halt | Emergency Halt |
|---|---|---|---|
| Vertex production | Continues (no quorum) | Stops | Stops |
| Commits | Paused | Paused | Paused |
| Tx submission | Accepted, queued | Accepted, queued | Accepted, queued |
| Decryption ceremonies | Paused | Stopped | Stopped |
| DKG ceremonies | Continues unless triggered | Stopped | Stopped |
| State queries | Continue | Continue (forensic) | Continue |
| Slashing evidence acceptance | Continues | Continues | Continues |
| Gossip | Continues | Continues | Continues |
Key invariant: slashing evidence accepted during halt. Attackers cannot escape consequences by triggering a halt.
Investigation Procedure (Hard / Emergency)
Phase 1: Triage (within 1 hour)
- Confirm halt type + trigger
- Identify affected commits / validators
- Snapshot forensic state (preserve)
- Public incident report (initial)
Phase 2: Root Cause Analysis (within 6-24 hours)
- Bug / attack / infrastructure failure?
- Determine scope of impact
- Coordinate with validator operators
- Develop fix or recovery plan
Phase 3: Recovery Plan (within 24-72 hours)
- Propose recovery strategy
- Validate plan with multisig + community
- Coordinate validator updates if needed
- Schedule resume timing
Recovery Procedures (5 Paths)
1. Wait It Out (Soft Stalls)
- Network/validator issues resolve naturally
- 85+ validators come back online
- Quorum forms, commits resume
- No intervention needed
- Typical: <30 minutes; >1 hour escalates
2. Software Update + Replay (Hard Halts from Bugs)
- Identify the deterministic bug causing state divergence
- Patch validator software
- Validators verify they're at consistent state
- Coordinate restart from last verified commit
- Replay txs from mempool
3. Rollback (Controversial, Severe Bugs)
- Roll back to last "clean" commit (max 1 epoch back — 3 hours)
- Discard commits after rollback point
- Re-execute affected txs
- Apply slashing to bad actors
- Limited window prevents catastrophic finality violations
4. Hard Fork (Irreconcilable Issues)
- Manual coordination via governance multisig
- Agreement on canonical state
- All validators update software
- Resume from agreed genesis-of-new-fork state
- Old chain abandoned
5. Emergency Unhalt (False-Positive Halts)
- Investigation reveals no actual issue
- Multisig releases halt
- Resume normally
Rollback Policy
Bounded operational pragmatism:
Maximum rollback window: 1 epoch (~3 hours)
Within window: governance multisig can authorize rollback
Beyond window: only hard fork (community coordination required)
Philosophy: weak finality with a sunset.
- Within 1 epoch: finality is "almost certain but reversible via emergency"
- After 1 epoch: finality is "irreversible without coordinated hard fork"
This is industry standard pattern (Solana de facto, Ethereum has emergency rollback procedures).
State Reconciliation After Rollback
1. All validators agree on rollback target (commit C)
2. Validators roll back state to C
3. Commits after C are discarded
4. Txs in those commits returned to mempool (if still valid)
5. Slashing applied to validators who produced bad-state-root sigs
6. Software updates applied if needed
7. Resume normal operation from C
8. New canonical fork is the post-rollback chain
Specific Scenario Playbooks
Scenario A: State Root Divergence in Commit N
- Detection: 2+ validators signed contradictory roots for commit N
- Action: hard halt automatic
- Investigation: which validators? what tx caused? bug or attack?
- Recovery: identify cause, patch validators, rollback to N-1, resume
- Slashing: validators with wrong root get bad-state-root-sig slash (10%+)
Scenario B: 43+ Committee Offline Simultaneously
- Detection: <85 quorum cannot form
- Action: soft stall
- Investigation: coordinated (attack) or correlated (datacenter outage)?
- Recovery: correlated → wait; coordinated → governance emergency halt to remove
- Slashing: extended downtime + possibly coordination evidence
Scenario C: Critical Bug Discovered (Off-Chain)
- Detection: human report to foundation
- Action: emergency halt via multisig
- Investigation: assess exploit, develop patch
- Recovery: coordinate validator update, resume after patch
- Slashing: none (no on-chain evidence)
Scenario D: DKG Ceremony Failed (Multiple Times)
- Detection: round 4 fails >3 consecutive
- Action: partial halt (encryption disabled for epoch)
- Investigation: which members not contributing? bug or attack?
- Recovery: rotate problematic members + retry DKG, OR continue without encryption
- Slashing: DKG-failure for non-participants
Scenario E: Detected DAG Fork
- Detection: contradictory subdags after commit
- Action: hard halt (this should be impossible per protocol)
- Investigation: deep protocol bug
- Recovery: hard fork to canonical chain, coordinate community
- Slashing: equivocation slashing for forking actors
Communication & Coordination
Halt detected → On-chain "ChainHalted" event emitted
↓
Validator dashboards display halt status
↓
Foundation publishes incident page (initial within 1 hour)
↓
Coordination channels active:
- Discord/Telegram: real-time
- Validator email list: critical comms
- Twitter/X: public status
↓
Resolution proposed
↓
Multisig signs ResumeMessage when ready
↓
On-chain "ChainResumed" event
↓
Public post-mortem within 7 days
Re-Entry After Halt
1. Multisig signals resume (or auto-resume for soft stalls)
2. Validators verify they're at consistent state
3. Mempool processes queued txs (validity re-checked against current state)
4. Commits resume normal cadence
5. Slashing evidence from halt period processed
6. System returns to normal operation
Test Plan / Drills
Mandatory before mainnet:
- Soft stall drills: deliberately offline 43 validators, verify recovery
- Hard halt drills: inject state divergence, verify detection + flow
- Emergency halt drills: practice multisig coordination
- Rollback drills: practice 1-epoch rollback procedure
- Hard fork drills: practice coordinated upgrade
Frequency: quarterly in testnet, annually in mainnet.
Documentation: runbooks for each scenario; updated after every drill.
The HotStuff Lesson Applied
HotStuff broke under wedges/stalls because there was no clear halt → investigate → recover procedure. The team patched live, accumulating safety subtleties.
Pyde's design EXPLICITLY:
- Separates the three halt types
- Defines authority + procedure for each
- Builds drills into the operational plan
This is the lesson learned from the pivot.
References
- Threat model: see THREAT_MODEL.md
- Failure scenarios (operational walk-through): see FAILURE_SCENARIOS.md
- Slashing: see SLASHING.md
Document version: 0.1
License: See repository root
Pyde Threat Model
Version 0.1
This is the canonical threat model for Pyde. It catalogs ~50 threats across 7 layers, maps each to its mitigation in the protocol design, and acknowledges residual risks.
This is a living document. Update on new threats discovered, protocol changes, and quarterly review.
Companion to Chapter 16. Chapter 16: Security is the narrative defense reference — it walks the same ground in essay form, explains why each defense was chosen, and is intended for readers building intuition. This document is the catalog: every threat carries an ID, severity, detection signal, and mitigation reference. External auditors should treat this document as the entry point; bug reporters should reference threat IDs from this catalog.
1. Scope & Assets
In Scope (Protocol Responsibility)
- User funds (PYDE balances + staked amounts)
- State integrity (no fork, no double-spend)
- Transaction ordering integrity (no proposer-MEV)
- Encryption invariants (commit-before-reveal)
- Validator stake (fair slashing)
- Privacy of encrypted transaction contents
- Liveness (chain progress)
- Cross-chain finality (HardFinalityCert correctness)
Out of Scope (User / Operational Responsibility)
- User wallet compromise (private key custody is the user's)
- Smart contract bugs in user-deployed WASM contracts (audit + safety features mitigate, but protocol doesn't enforce)
- RPC provider failures (orthogonal infrastructure)
- Single-node hardware failures (operator responsibility, mitigated by redundancy)
- Social engineering of multisig holders (organizational responsibility)
- Future quantum compute attacks on archived encrypted transactions (no defense possible)
- Application-layer DDoS (dApp choosing weak rate limits)
Asset Value Classification
| Asset | Value | Loss impact |
|---|---|---|
| User funds | Critical | Direct financial loss to users |
| State integrity | Critical | Chain becomes untrustworthy |
| MEV resistance | Critical | Core value proposition |
| Validator stake | High | Slashing must be fair |
| Liveness | High | Chain stops being useful |
| Privacy | High | Encryption promise violated |
| Cross-chain integrity | High | Bridges hacks have caused $3B+ historical losses |
2. Adversary Model
Adversary Types
| Type | Motivation | Resources | Likelihood |
|---|---|---|---|
| MEV bot operator | Profit | Modest infrastructure, deep mempool knowledge | High |
| Economic actor | Profit (large) | Significant capital, can stake | Medium |
| Coordinated cartel | Combined economic gain | Large stake + infrastructure | Medium |
| State adversary | Geopolitical, censorship | Nation-state resources, BGP control | Low but high-impact |
| Insider (validator) | Profit, sabotage | Has stake, share, software access | Low but high-impact |
| Cryptographic adversary | Research or destruction | Mathematician + compute | Low |
| Quantum adversary | Long-term destruction | Future quantum computer | Very low (decade+) |
| Network adversary | Disruption | ISP / BGP position | Low |
| Software supply chain | Various | Dependency access | Medium |
| Social attacker | Various | Social skills | Medium |
Adversary Capabilities
Default network adversary (Dolev-Yao):
- ✅ Observe public messages
- ✅ Delay, reorder, drop, duplicate messages
- ✅ Spoof network packets
- ❌ Cannot forge FALCON signatures
- ❌ Cannot decrypt without ≥85 shares
- ❌ Cannot find hash collisions in Blake3 or Poseidon2
Insider validator (single):
- ✅ Has one FALCON private key
- ✅ Has one threshold decryption share
s_i - ✅ Has validator software access
- ❌ Cannot reconstruct shared SK alone
- ❌ Cannot forge other validators' signatures
- ❌ Cannot violate determinism alone (constrained by protocol rules)
Coordinated insiders (≤42 validators, below BFT threshold):
- ✅ Can collectively decrypt nothing (need 85)
- ✅ Can equivocate (each commits slashable offense)
- ✅ Can collude on transactions (but ordering is deterministic)
- ❌ Cannot violate safety (need 85+ for any commit)
- ❌ Cannot censor (other 86+ can include any transaction)
Coordinated insiders (≥85 validators, above BFT threshold):
- ✅ Can decrypt encrypted transactions
- ✅ Can commit to invalid states (others detect and halt)
- ✅ Can censor
- ✅ Can fork the chain
- This is the "BFT broken" scenario — out of normal protocol scope. Residual risk.
3. Trust Assumptions
Cryptographic
- FALCON-512 is EUF-CMA secure (NIST standard)
- Kyber-768 is IND-CCA2 secure (NIST FIPS 203)
- Blake3 and Poseidon2 are collision-resistant
- DKG produces a valid threshold key under honest majority
- Random beacon is unpredictable until reveal
Network
- Partially synchronous: messages eventually delivered (no permanent partition)
- Clock skew bounded (~5 seconds maximum)
- At least one honest path exists between any two honest nodes
Validator Behavior
- ≥85 of 128 committee members are honest (BFT supermajority)
- Honest nodes follow the protocol; slashing punishes deviation
- Validator software is correctly implemented (defense via formal methods + audits)
Operational
- Genesis ceremony participants are honest
- Hardcoded seed nodes are operated honestly
- DNS infrastructure is reliable
- Foundation multisig members are not compromised (>4 of 7 honest for 7-of-12 threshold)
4. Threat Catalog
Consensus Layer
| ID | Threat | Severity | Detection | Mitigation |
|---|---|---|---|---|
| T-CONS-1 | Equivocation (validator signs contradictory messages) | High | Cryptographic evidence | Equivocation slashing 10-50% |
| T-CONS-2 | Long-range attack (rewrite history) | Medium | State root signatures, finality | Bounded rollback (1 epoch), weak-subjectivity checkpoints |
| T-CONS-3 | Bad state-root signing | High | Contradictory roots for same commit | Bad-state-root slashing 10%, correlation multiplier |
| T-CONS-4 | Anchor predictability exploitation | Medium | Public beacon analysis | Lookback state-root randomness |
| T-CONS-5 | Adaptive corruption (mid-epoch) | Medium | Liveness slashing | Epoch boundary commitment, slashing accumulation |
| T-CONS-6 | Slashing race (withdraw before slash applies) | High | Unbonding period | Unbonding (30d) > evidence freshness (21d) |
| T-CONS-7 | DAG cycle / invalid parent refs | Critical | Structural validation | Auto-reject vertex, slash producer |
| T-CONS-8 | Coordinated proposer attack | High | DAG has no proposer | Structurally impossible |
Cryptographic Layer
| ID | Threat | Severity | Detection | Mitigation |
|---|---|---|---|---|
| T-CRYPT-1 | FALCON key compromise (single validator) | Medium | Anomaly detection | Key rotation, HSM recommended |
| T-CRYPT-2 | Kyber threshold compromise (≥85) | Critical | DKG output | Honest BFT majority assumption; per-epoch refresh |
| T-CRYPT-3 | Hash collision (Blake3 / Poseidon2) | Very low | Cryptanalysis | Standardized primitives, dual hash strategy |
| T-CRYPT-4 | Threshold decryption side-channel | Low | Audit | Constant-time implementation |
| T-CRYPT-5 | DKG manipulation (force bad key) | Medium | DKG validation | Pedersen DKG with public commitments, slashing |
| T-CRYPT-6 | Random beacon bias | Medium | Output analysis | Threshold-sig beacon (no single party controls) |
| T-CRYPT-7 | Future quantum on archived encrypted txs | Long-term | N/A | Out of scope; PQ primitives best available |
MEV / Economic Layer
| ID | Threat | Severity | Detection | Mitigation |
|---|---|---|---|---|
| T-MEV-1 | Front-running via early decryption | High | N/A | Commit-before-reveal invariant enforced |
| T-MEV-2 | Sandwich attacks | High | N/A | Plaintexts hidden until order committed |
| T-MEV-3 | Liquidation racing | Medium | N/A | Mitigated by encryption + commit-before-reveal |
| T-MEV-4 | Time-bandit attacks | High | Finality | Bounded rollback, slashing |
| T-MEV-5 | Validator-builder collusion | Medium | N/A | No proposer-builder separation; DAG eliminates surface |
| T-MEV-6 | Stake concentration → control 43+ committee | High | Public stake state | Anti-Sybil (operator identity cap), stake cap |
| T-MEV-7 | Bribery of committee for ordering | Medium | Behavior analysis | Equal-power voting + slashing makes bribery expensive |
| T-MEV-8 | Censorship (selective exclusion) | High | Detection hard | 127 others can include; censorship requires near-unanimous |
Network Layer
| ID | Threat | Severity | Detection | Mitigation |
|---|---|---|---|---|
| T-NET-1 | Eclipse attack (isolate target) | Medium | Peer diversity analysis | Anti-eclipse: diverse IPs/ASNs, persistent peers |
| T-NET-2 | DDoS on committee validator | High | Traffic analysis | Sentry node pattern, rate limits, peer scoring |
| T-NET-3 | BGP hijack / route manipulation | Low (rare) | Out-of-band | Out of scope (network responsibility) |
| T-NET-4 | Sybil on peer discovery | Medium | IP/ASN concentration | Layered discovery (not DHT), peer score |
| T-NET-5 | Message flooding / spam | Medium | Rate limits | Per-peer rate limiting, gas tank requirement |
| T-NET-6 | Network partition (deliberate or accidental) | Medium | Quorum detection | Partition-aware slashing pause; halt detection |
Economic / Governance Layer
| ID | Threat | Severity | Detection | Mitigation |
|---|---|---|---|---|
| T-ECON-1 | Stake concentration (rich operator, many cheap validators) | High | On-chain analysis | Operator identity binding, max 3 per operator |
| T-ECON-2 | Validator collusion (43+ coordinated offline DoS) | High | Quorum detection | Slashing + partition handling |
| T-ECON-3 | Treasury attacks (governance capture) | Medium | Public proposals | Off-chain governance, transparent PIP process |
| T-ECON-4 | Multisig compromise (emergency halt abuse) | High | Multi-key threshold | 7-of-12 multisig, slashable malicious unhalt |
| T-ECON-5 | Token price collapse → slashing economics broken | Medium | Market data | Numbers tunable, treasury can adjust |
Software / Implementation Layer
| ID | Threat | Severity | Detection | Mitigation |
|---|---|---|---|---|
| T-SW-1 | WASM execution non-determinism bug | Critical | State root divergence | Extensive testing, formal verification, halt detection |
| T-SW-2 | Toolchain binding-generator bug | High | Contract test failures | Per-language generator audits, fuzz testing across all four targets |
| T-SW-3 | FALCON sig side-channel | Low | Timing analysis | Constant-time implementation |
| T-SW-4 | Memory corruption (buffer overflow) | High | Rust borrow checker, audits | Use safe Rust, audit unsafe blocks |
| T-SW-5 | Cryptographic library bug | High | Audits | Use well-audited libraries (RustCrypto) |
| T-SW-6 | State corruption (disk errors) | Medium | Snapshot verification | JMT root recomputation, peer cross-verification |
Authorization Layer (v2 — session keys + programmable accounts)
Session keys ship at v2. The threats below are catalogued now so the v2 implementation lands against a known surface. Until v2, the AuthKeys::Programmable variant is reserved-but-disabled — these threats are inactive at v1.
| ID | Threat | Severity | Detection | Mitigation |
|---|---|---|---|---|
| T-AUTH-1 | Session-key theft (compromised dApp leaks key) | Medium | User notification; on-chain anomaly (unusual spend pattern within scope) | Limited blast radius via scope (contracts + methods + spend cap + expiry); user can revoke instantly with a single signed tx; main auth_keys untouched |
| T-AUTH-2 | Revoked-key replay (attacker submits tx signed by previously-revoked session key) | Low | Authorization-time revoked check | Revocation is on-chain state; tx rejected at validation with KeyRevoked |
| T-AUTH-3 | Scope expansion via mutable storage manipulation | High | Policy WASM audit | Policy WASM runs in restricted-state mode; cannot modify own scope without main-key signature on a RegisterSessionKey/UpdateScope tx |
| T-AUTH-4 | Session-key squatting (creating many keys to flood storage) | Low | Per-account session-key count | Hard limit (32 active session keys per account); spent storage refunded on revocation |
| T-AUTH-5 | spent_so_far overflow attack | Low | u128 arithmetic checks at authorization | Saturating addition + max_spend ≤ u128::MAX / 2 registration check |
| T-AUTH-6 | Expired-key acceptance (clock skew at wave boundary) | Low | Authorization-time expires_at check | Wave is the authoritative clock; no off-chain time source enters the check |
Social Layer
| ID | Threat | Severity | Detection | Mitigation |
|---|---|---|---|---|
| T-SOC-1 | Phishing of operators / multisig | High | Out-of-band | Operator training, HSM, multisig for high-value ops |
| T-SOC-2 | Misinformation during incident | Medium | Multiple channels | Foundation as authoritative source, clear comms protocol |
| T-SOC-3 | Insider threat (developer / foundation) | Medium | Code review, multisig | Multi-sig deployments, public PIP review |
| T-SOC-4 | Supply chain attack on dependencies | High | Cargo.lock audit | Reproducible builds, dependency review |
5. Mitigation Cross-Reference
| Mitigation | Specification |
|---|---|
| BFT 85/128 quorum + Mysticeti-style consensus | See WHITEPAPER §5 |
| Slashing | See SLASHING.md |
| Threshold encryption + commit-before-reveal | See WHITEPAPER §4, §8 |
| Anti-Sybil (operator identity binding) | See VALIDATOR_LIFECYCLE.md |
| State sync verification (chain-of-trust) | See STATE_SYNC.md |
| Chain halt + recovery procedures | See CHAIN_HALT.md |
| Network defenses (DoS, eclipse) | See NETWORK_PROTOCOL.md |
| Performance harness validates resilience | See PERFORMANCE_HARNESS.md |
| Equal-power committee | See WHITEPAPER §5.5 |
| Honest throughput claims | See WHITEPAPER §11 |
6. Residual Risks (Acknowledged, Not Fully Mitigated)
These are risks Pyde cannot fully eliminate:
-
Coordinated 85+ validator collusion — out of BFT scope. If 85+ collude, safety can be violated. Mitigation: economic disincentives + stake distribution + operator identity cap.
-
Quantum compute breaking PQ primitives in <10 years — not currently feasible to defend; PQ choice is the best available.
-
Smart contract bugs in user-deployed WASM contracts — out of protocol scope. Mitigation: Pyde safety attributes (reentrancy off by default, checked arithmetic) preserved in the WASM era + recommended user audits.
-
Single-validator key compromise — validator loses ≤1 vote of influence. Mitigation: key rotation, HSM, multisig validator (v2 feature).
-
Foundation multisig compromise — 7+ of 12 hostile = emergency halt abuse. Mitigation: diverse multisig members, public visibility, slashable malicious unhalt.
-
Network-level adversary (BGP, ISP) — out of protocol scope. Mitigation: encourage geographic + provider diversity.
-
Genesis trust — initial committee, hardcoded seeds, hardcoded committee pubkeys all require founder trust. Unavoidable at chain launch.
7. Update Procedure
The threat model is a living document:
- Update triggers:
- New threats discovered (research, incidents, audits)
- Protocol changes (new features → new attack surfaces)
- Quarterly review (mandatory)
- Format for new threat entry:
- T-XXX-N: <name> - Severity: <Critical / High / Medium / Low> - Discovered: <date / source> - Detection: <how detected> - Mitigation: <how addressed or "residual risk"> - Reference: <design doc section> - Each major update increments the version number.
8. For Auditors
This document is the entry point for external security review. Auditors should:
- Verify the threat catalog is complete (no missing categories)
- Verify each mitigation is actually implemented (trace to code)
- Verify residual risks are acceptable for the asset values
- Verify trust assumptions are reasonable for production
- Test selected scenarios (especially from FAILURE_SCENARIOS.md)
Document version: 0.1
License: See repository root
Pyde Failure Scenarios
Version 0.1
Operational walk-throughs of failure modes. Complements THREAT_MODEL.md (what attacks exist) with step-by-step recovery procedures.
General Incident Response Timeline
T+0:00 Detection (auto or manual)
T+0:05 On-call notified
T+0:15 Triage call initiated
T+0:30 Initial incident page published
T+1:00 Root cause investigation begins
T+6:00 Recovery plan proposed
T+24:00 Recovery executed (straightforward cases)
T+72:00 Resolution + initial post-mortem
T+7d Full public post-mortem published
T+30d Drill that scenario in testnet
Communication Protocol
- Authoritative source: foundation incident page + Discord #incidents
- Status page: pyde.network/status (always updated)
- Validator coordination: private email list + dedicated Discord channel
- Public: Twitter/X status updates every 30 min during active incident
The 12 Scenarios
Scenario 1: Single Validator Offline (Hardware Failure)
- Trigger: Validator's server crashes (disk, power, etc.)
- Detection: Auto-detected within 2 rounds (no vertex from validator)
- Initial Response: None needed — other 127 continue normally
- Investigation: Operator diagnoses (off-chain)
- Recovery: Operator replaces hardware, runs state sync, resumes
- Time to Recovery: 4-24 hours
- Slashing: Downtime accumulates (~0.05%/round)
- Drill Frequency: Quarterly
Scenario 2: Validator Key Compromise
- Trigger: Operator's key stolen (phishing, server intrusion)
- Detection: Unusual signing patterns OR operator reports
- Initial Response:
- Operator: rotate to new key immediately
- Foundation: investigate scope
- Other validators: monitor for collusion
- Investigation: Forensic analysis, attribution if possible
- Recovery: Key rotation, possibly fresh validator slot if old one slashed
- Time to Recovery: 1-7 days
- Slashing: Whatever the attacker did with the key
- Lessons: HSM strongly recommended; key rotation procedures documented
- Drill Frequency: Annual paper drill
Scenario 3: Network Partition (30% Split for 1 Hour)
- Trigger: BGP routing issue, undersea cable cut, ISP outage
- Detection:
- Active committee count drops below 85 (quorum threshold = 2f+1, f=42)
- Soft stall triggered automatically
- Downtime slashing PAUSES (partition-aware)
- Initial Response:
- Validators in majority partition: keep producing vertices
- Validators in minority: cannot reach quorum, stall
- No coordination needed (automatic handling)
- Investigation: Root cause analysis (network team)
- Recovery:
- Network heals
- Minority validators rejoin gossip
- DAG resynchronizes
- Slashing resumes
- Time to Recovery: Hours (depends on network)
- Slashing: None during partition (partition-aware pause)
- Drill Frequency: Quarterly (simulate in testnet)
Scenario 4: State Root Divergence Detected
- Trigger: Bug in WASM execution layer or non-determinism
- Detection: Auto — 2+ validators sign contradictory state roots for same commit → hard halt
- Initial Response:
- All validators halt
- Forensic state preserved
- Incident page published
- Investigation:
- Identify which validators signed which root
- Determine which root is "correct"
- Identify bug causing divergence
- 6-24 hours
- Recovery:
- Patch the bug
- Validators update software
- Roll back to last consistent commit (within 1-epoch window)
- Resume from rolled-back state
- Slash validators who signed wrong roots
- Time to Recovery: 24-72 hours
- Slashing: Bad-state-root-sig (~10%) to validators on wrong fork
- Lessons: WASM execution determinism testing must improve; add new test cases
- Drill Frequency: Quarterly (inject in testnet)
Scenario 5: DKG Ceremony Fails Repeatedly
- Trigger:
- Several committee members go offline mid-DKG
- DKG round 3 messages don't reach validators
- Bug in DKG implementation
- Detection: DKG round 4 verification fails for >3 consecutive attempts → partial halt (encryption disabled for this epoch)
- Initial Response:
- Identify which members not contributing valid shares
- Decide: retry vs. continue without encryption
- Investigation:
- Per-member: offline, buggy, or malicious?
- Network issues vs. software bug
- Recovery (options):
- A: Retry DKG with backup committee members
- B: Continue without encryption for this epoch
- C: Replace problematic members from the validators-awaiting-selection pool
- Time to Recovery: Same epoch (~3 hours) or next epoch
- Slashing: DKG-failure for non-contributors (~5%)
- Drill Frequency: Annual
Scenario 6: Critical Execution Layer Bug (Off-Chain Disclosure)
- Trigger: Security researcher reports vulnerability via responsible disclosure
- Detection: Email to
security@pyde.network - Initial Response:
- Within 1 hour: foundation reviews + confirms severity
- If critical + active exploit risk: emergency halt via multisig
- If critical + no immediate risk: 24-72 hour disclosure window
- Investigation:
- Reproduce the bug
- Develop patch
- Test patch
- Coordinate validator updates
- Recovery:
- All validators update software simultaneously
- Coordinated restart if needed
- Public disclosure + acknowledgment + bounty payment
- Time to Recovery: 24-72 hours
- Slashing: None (no on-chain offense)
- Lessons: Strong bug bounty program; clear disclosure policy
- Drill Frequency: Annual paper drill
Scenario 7: Active Exploit Being Used
- Trigger: Foundation observes attacker draining funds
- Detection: On-chain monitoring tools, validator reports
- Initial Response: Emergency halt within 15 minutes via multisig
- Investigation:
- Identify exploit mechanism (fast)
- Calculate scope of damage
- Identify attacker addresses if possible
- Recovery:
- Patch the exploit
- Validator update
- Rollback if within 1-epoch window (controversial)
- OR resume without rollback (user funds lost)
- Compensation plan from treasury if available
- Time to Recovery: 24-72 hours
- Slashing: None (off-chain attack)
- Lessons: Better monitoring; multisig response speed critical
- Drill Frequency: Annual simulated
Scenario 8: Foundation Multisig Key Lost / Compromised
- Trigger: Holder loses key (HW failure) OR key stolen
- Detection: Holder reports loss OR unusual multisig activity observed
- Initial Response:
- Lost: holder coordinates with other multisig members for replacement
- Stolen: investigate scope, secure remaining keys
- Investigation: Verify identity of remaining holders; forensic if stolen
- Recovery:
- Replace lost/compromised key via multisig vote
- May need genesis-update if all keys at risk
- Update on-chain multisig configuration
- Time to Recovery: Days to weeks
- Slashing: None (operational)
- Lessons: Diverse holders, geographic distribution, HSM
- Drill Frequency: Annual paper drill
Scenario 9: Major Cloud Provider Outage (AWS us-east-1)
- Trigger: Cloud provider region outage
- Detection: 30-60% of validators in that region go offline
- Initial Response: Validators outside affected region continue if quorum maintained
- Investigation: Identify cause (provider's issue, not Pyde's)
- Recovery:
- Cloud provider recovers
- Validators come back online
- Network catches up
- Slashing PAUSED during partition
- Time to Recovery: Hours (depends on provider)
- Slashing: None (partition-aware)
- Lessons: Validator diversity matters; encourage multi-provider, multi-region
- Drill Frequency: Quarterly multi-region resilience test
Scenario 10: Coordinated 43-Validator Attack
- Trigger: 43 validators coordinate to attack (offline or equivocate)
- Detection: Real-time monitoring shows coordinated behavior
- Initial Response:
- 43 offline: stall (auto), need governance to remove if persistent
- 43 equivocating: massive slashing events
- Investigation: Identify coordinator; collect cryptographic evidence
- Recovery:
- 43 offline: emergency halt + governance removal
- 43 equivocating: slash all 43 (correlation multiplier = 2× → full bond)
- Network resumes with remaining 85+ honest
- Time to Recovery: 24-72 hours
- Slashing: Up to 100% × 43 validators (correlation max)
- Lessons: This is the BFT boundary; design defends but at cost
- Drill Frequency: Annual paper-only (too disruptive for testnet)
Scenario 11: Memory Leak Causing Rolling Restarts
- Trigger: Bug causes validator memory to grow unbounded
- Detection:
- Operator notices RSS growing
- Performance dashboards show abnormal memory
- OOM crashes
- Initial Response:
- Identify affected validators
- Restart affected (each)
- Investigation:
- Heap profiling
- Identify leaked structure
- Patch the bug
- Recovery:
- Software update
- Rolling restart (not simultaneous)
- Time to Recovery: Hours to days
- Slashing: Downtime for extended restarts
- Lessons: Better memory profiling, soak testing
- Drill Frequency: Continuous (every soak test)
Scenario 12: Genesis State Inconsistency Discovered
- Trigger: After mainnet launch, discrepancy found in genesis state
- Detection: Foundation review, validator report
- Initial Response:
- Determine if functional or cosmetic
- If functional: emergency halt
- Investigation:
- Identify cause (founder error, hardcoded discrepancy)
- Calculate impact
- Recovery:
- Cosmetic: file a note, no action
- Functional: hard fork required (re-genesis or state correction)
- Time to Recovery: Days to weeks (hard fork is coordination-heavy)
- Slashing: None (genesis issue)
- Lessons: Genesis review must be thorough; multiple parties verify
- Drill Frequency: Pre-launch paper review only (irreversible post-launch)
Generalized Lessons
| Pattern | Recommendation |
|---|---|
| Multiple validators affected together | Encourage geographic + provider + ISP diversity |
| Operational mistakes | HSM, multisig for critical ops, runbooks |
| Software bugs | Bug bounty, formal verification, extensive testing |
| Network issues | Partition-aware slashing, sentry nodes, diverse routes |
| Time to recovery | Pre-rehearsed drills > improvising under pressure |
Runbook Library Structure
Each scenario should have a written runbook:
runbooks/
├── 01-validator-offline-single.md
├── 02-validator-key-compromise.md
├── 03-network-partition.md
├── 04-state-root-divergence.md
├── 05-dkg-failure.md
├── 06-execution-bug-disclosed.md
├── 07-active-exploit.md
├── 08-multisig-key-event.md
├── 09-cloud-provider-outage.md
├── 10-coordinated-attack.md
├── 11-memory-leak.md
├── 12-genesis-discrepancy.md
└── README.md (decision tree → which runbook)
Each runbook contains: trigger conditions, detection criteria, step-by-step response (commands to run, calls to make), recovery procedures, escalation paths, communication templates, post-incident checklist.
Drill Schedule
| Drill | Frequency | Format |
|---|---|---|
| Validator restart | Quarterly | Live (testnet) |
| Network partition | Quarterly | Live (testnet) |
| State root divergence | Quarterly | Live (testnet, injection) |
| DKG failure | Annual | Live (testnet) |
| Active exploit | Annual | Simulated |
| Coordinated attack | Annual | Paper only |
| Key compromise | Annual | Paper only |
| Multisig key event | Annual | Paper only |
| Genesis discrepancy | Pre-launch only | Paper review |
| Cloud outage | Quarterly | Live (testnet, region isolation) |
Track every drill: time-to-detect, time-to-respond, time-to-recover. Improve runbooks based on observed gaps.
Integration with Other Documents
- Threat model: see THREAT_MODEL.md for the "what could attack us"
- Chain halt: see CHAIN_HALT.md for halt mechanics
- Performance harness: see PERFORMANCE_HARNESS.md for chaos testing infrastructure
- Slashing: see SLASHING.md for slashing details
Document version: 0.1
License: See repository root
Pyde Network Protocol
Version 0.1
Transport, peer discovery, gossip, message types, DoS protections, and committee defense patterns.
Transport & P2P Library
| Choice | Rationale |
|---|---|
| Transport: QUIC (over UDP) | No HOL blocking, built-in TLS 1.3, mature in Rust (quinn) |
| Fallback: TCP | Compatibility for restrictive networks |
| Library: libp2p (Rust) | Mature, audited, used by Ethereum/Filecoin/Polkadot |
| Node ID: Ed25519 keypair (separate from validator FALCON) | Stable network identity, rotatable without affecting validator status |
Peer Discovery: Layered Bootstrap
Layer 1: Hardcoded seeds (5-10 stable, foundation-operated)
↓
Layer 2: DNS seeds (~10 more peer addresses)
↓
Layer 3: Validator registry (on-chain — committee members publish addresses)
↓
Layer 4: Peer Exchange (PEX) — peers tell each other about peers
↓
Layer 5: Persistent peer set (preserved across restarts)
No DHT
Kademlia DHT (used by IPFS, Filecoin) is for content discovery. Pyde is a chain — peers are limited and known. DHT adds complexity without benefit.
Why layered > DHT for Pyde:
- ✅ Peer identity is on-chain (validator FALCON-bound)
- ✅ Sybil cost is real (
MIN_VALIDATOR_STAKE= 10K PYDE + operator-identity cap of 3 per operator) - ✅ Far simpler (~1K LOC vs ~10K LOC for DHT)
- ✅ Faster discovery (single-hop vs multi-hop)
- ✅ Smaller audit surface
Comparable approaches: Bitcoin, Cosmos, Solana all use layered (no DHT). Ethereum uses both but primarily layered.
Bootstrap Sequence (First Launch)
1. Try hardcoded seeds first (5-10 stable, foundation-operated)
2. Resolve DNS seeds (~10 more peer addresses)
3. Query validator registry on-chain (all staked validators — active committee + awaiting selection)
4. Establish connections to N peers (default N=20)
5. Run PEX to discover more peers
6. Persist successful peers to disk for next startup
Connection Management
| Parameter | Default | Notes |
|---|---|---|
MAX_CONNECTIONS | 200 | Tunable |
MIN_OUTBOUND_CONNECTIONS | 8 | Tunable |
MAX_CONNECTIONS_PER_IP | 5 | Tunable |
MAX_CONNECTIONS_PER_ASN | 50 | Anti-clustering |
INBOUND_CONNECTION_LIMIT | 100 | Tunable |
CONNECTION_TIMEOUT | 10s | Tunable |
HANDSHAKE_TIMEOUT | 5s | Not tunable (security) |
Per-Role Recommendations
- Committee validators: 30-50 active peers (reliability + low-latency)
- Full nodes: 10-20 active peers (default)
- Light clients: 3-5 active peers
Churn Handling
- Lost connection → reconnect with backoff (1s, 5s, 30s, 5min, 30min)
- Persistent failure → demote from "preferred" list
- Misbehaving → ban with TTL (1h, 6h, 24h, permanent)
Message Types & Hard Size Limits
| Type | Priority | Typical | Hard Limit |
|---|---|---|---|
| Ping / Pong | Low | 16B | 64B |
| PeerExchange | Low | 1KB | 8KB |
| VertexAnnouncement | High | 40B | 64B |
| VertexRequest | High | 32B | 64B |
| VertexData | High | 4KB | 64KB |
| BatchAnnouncement | Med | 40B | 64B |
| BatchRequest | Med | 32B | 64B |
| BatchData | Med | 50-200KB | 4MB |
| DecryptionShare | High | 1KB | 2KB |
| StateRootSig | High | 738B | 1KB |
| TxSubmission (plain) | Med | 500B | 8KB |
| TxSubmission (encrypted) | Med | 1.5KB | 8KB |
| ManifestRequest | Low | 32B | 64B |
| ManifestData | Low | 5KB | 64KB |
| ChunkData (state sync) | Low | 4MB | 4MB |
Enforcement Pattern
#![allow(unused)] fn main() { trait Message { const MAX_SIZE: usize; fn validate_size(len: usize) -> Result<()>; } // At parse time: // 1. Read message type tag (1 byte) // 2. Read payload length (4 bytes) // 3. CHECK against max_size BEFORE allocating buffer // 4. If too large: reject + peer score penalty (+5 points) // 5. If OK: read payload, deserialize, process }
Memory safety, DoS resistance, predictability, audit-friendliness all depend on explicit limits.
BatchData Sizing
| Hard Limit | Modest hardware fit | Theoretical batch-implied ceiling |
|---|---|---|
| 2 MB | Strongest | Lowest |
| 4 MB (chosen) | Strong | Moderate |
| 8 MB | Mixed | Higher |
| 16 MB | Aspirational | Highest |
4 MB hard limit balances modest-hardware committee promise (≥500 Mbps NIC sufficient for v1's honest throughput target, which is to be established by the multi-region performance harness, with headroom in the batch size for post-mainnet scaling) with realistic burst scenarios (NFT mints up to ~2000 encrypted txs in one batch). The theoretical-ceiling column above is implied by the batch limit; the v1 honest target is much lower (see honest throughput reset).
For batches >4 MB: chunked transfer (BatchAnnouncement → multiple BatchChunk messages of 4 MB each).
Gossip Protocol: Gossipsub
Pyde uses libp2p's Gossipsub for message propagation. Industry standard.
How It Works
- Each node maintains "meshes" per topic (subscribed peers, default 6-8)
- Messages flood through the mesh first
- Lazy push: message IDs (8 bytes) sent more broadly; full message pulled on demand
- Heartbeat every second prunes / repairs mesh
Pyde Topics
| Topic | Subscribers |
|---|---|
pyde/vertices/<epoch> | All committee + full nodes |
pyde/batches/<shard> | All committee workers + RPC nodes |
pyde/decryption_shares/<commit> | All committee |
pyde/state_root_sigs/<commit> | All committee + full + light |
pyde/mempool/plain | All validators + RPC nodes |
pyde/mempool/encrypted | All validators + RPC nodes |
pyde/state_sync/manifests | Sync-mode nodes |
Parameters (Battle-Tested Defaults)
- Mesh size D = 8 (target peers in mesh)
- Fanout = 6 (peers for non-mesh delivery)
- Heartbeat interval = 1s
- Message TTL = 60s
DoS Protections (Multi-Layer)
Layer 1: Connection-Level
- Max connections per IP/ASN (already specified)
- Token bucket per connection
- Slow-loris protection (handshake timeout)
- Reject obviously malformed traffic at OS level (iptables hints to ops)
Layer 2: Message-Level Rate Limits
| Limit | Default | Per |
|---|---|---|
| Vertex announcements | 10/s | Per peer |
| Vertex data requests | 20/s | Per peer |
| Batch announcements | 100/s | Per peer |
| Batch data requests | 50/s | Per peer |
| Tx submissions | 100/s | Per peer (lower for unknown) |
| State sync requests | 10/min | Per peer |
| PEX requests | 1/min | Per peer |
Exceeding rate → drop messages silently. Repeated exceedance → ban.
Layer 3: Peer Scoring
#![allow(unused)] fn main() { struct PeerScore { successful_messages: u64, failed_messages: u64, invalid_messages: u64, avg_latency_ms: u32, bandwidth_used: u64, misbehavior_points: i32, last_misbehavior: Timestamp, } }
Misbehavior point assignments:
- Invalid sig: +10 points
- Malformed message: +5 points
- Duplicate spam: +2 points
- Slow / timeout: +1 point
Thresholds:
- 50 points → throttle (reduce priority, drop low-prio messages)
- 100 points → temp ban (1 hour)
- 200 points → longer ban (24 hours)
- 500 points → permanent ban
Points decay over time (1 point per hour) — rewards good behavior over time.
Layer 4: Application-Level
- Tx submission rate limit per sender address
- Gas tank prepayment (legacy
gas_tankfield) — pay-as-you-go for ingress - Resource caps on processing (CPU, memory per operation)
Bandwidth Prioritization (When Constrained)
Priority queue (top = highest):
1. State root sigs (consensus finality)
2. Vertex broadcasts (consensus structure)
3. Decryption shares (encrypted tx finality)
4. Batch announcements + small data
5. Tx submissions (mempool)
6. State sync chunks (background)
7. PEX, ping/pong (low frequency)
Per-peer bandwidth caps prevent any single peer from monopolizing.
Committee members can configure higher priority for vertex/share traffic.
Sentry Node Pattern (for Committee Validators)
DoS-vulnerable validators (committee members) should NOT expose to the public internet. Standard pattern:
Public Internet
↓
Sentry Node 1, 2, 3 (public-facing)
↓ (private network)
Committee Validator (NOT internet-exposed)
Sentries:
- Run by same operator (or trusted relays)
- Filter incoming traffic
- Forward only valid messages to validator
- Absorb DDoS attacks
Cost: 2-3× infrastructure per validator. Standard practice. Cosmos chains all use this.
Network Identity & Validator Binding
Three layers of identity:
- Network ID (Ed25519): used by libp2p for connection-level identity. Rotatable.
- Validator FALCON pubkey: consensus identity, registered on-chain. Rotatable per epoch.
- Operator stake account: ownership, slashing target. Stable.
Binding: validator's FALCON pubkey is signed by their stake account.
Publishing committee network IDs (in account state) for active epoch enables direct peer connections; mapping cleared after epoch ends to limit DoS targeting outside committee duty.
Anti-Eclipse Protections
Eclipse attack: adversary surrounds a node with malicious peers, controls their view of the network.
Defenses:
- Maintain peers from diverse IPs / ASNs
- Persistent peers (preserve across restarts)
- Random peer rotation (drop oldest every N hours)
- Mandatory connections to "well-known" peers (foundation, reputable infra) — optional
State Sync Network Behavior
State sync chunks are large (4 MB). Special handling:
- Lower priority than consensus traffic
- Dedicated bandwidth budget (e.g., max 20% of available)
- Peers can opt-out of being state sync sources
- Sync nodes maintain separate connection pool for chunk fetching
Connection Diagram
[Light Client]
(3-5 peers)
|
↓ State queries via libp2p
|
[Full Node / RPC]
(10-20 peers)
|
↓ Gossip vertices, batches
|
[Public Sentry Nodes]
(filtering)
|
↓ Filtered traffic only
|
[Committee Validator]
(30-50 peers, private mesh)
References
- Transport details: see WHITEPAPER.md §9
- Performance impact: see PERFORMANCE_HARNESS.md
- Threat model (network threats): see THREAT_MODEL.md §4 Network Layer
Document version: 0.1
License: See repository root
Pyde Performance Harness
Version 0.1
The gate before any external TPS claim. This is testing infrastructure that protects against the HotStuff trap: claimed numbers production cannot reproduce.
Why
Pyde's pre-pivot HotStuff implementation hit ~4K TPS in practice despite claims of higher. The lesson: lab benchmarks ≠ production. Performance harness is what prevents repeat.
All Pyde TPS claims must come from harness output, never from microbenchmarks or local devnet measurements.
Goals
- Reproducibly measure end-to-end performance under realistic conditions
- Detect regressions automatically on code changes
- Validate claims before they're published externally
- Find limits before they bite in production
- Generate audit trail of "this is how we know X is true"
Architecture
pyde-bench/
├── topology/ # Network topology configurations
│ ├── single_region.toml (8-16 validators, same DC)
│ ├── multi_region.toml (3 regions, geographic distribution)
│ └── production_sim.toml (full 128 validators, 3+ regions)
├── workloads/ # Workload generators
│ ├── transfers.rs (simple PYDE transfers)
│ ├── contract_calls.rs (WASM contract interactions)
│ ├── encrypted_swaps.rs (Kyber-encrypted, MEV-sensitive)
│ ├── nft_mint_burst.rs (burst pattern simulation)
│ └── mixed.rs (realistic distribution)
├── metrics/ # Metrics collection + reporting
│ ├── collector.rs (per-validator scraping)
│ ├── prometheus.rs (export to Prometheus/Grafana)
│ └── reporter.rs (markdown/HTML reports)
├── chaos/ # Chaos engineering
│ ├── validator_kill.rs (random validator restarts)
│ ├── network_partition.rs (split-brain testing)
│ ├── slow_peer.rs (latency injection)
│ └── adversarial.rs (bad-actor behaviors)
├── soak/ # Long-duration test runners
└── reports/ # Output formats
Test Topologies
| Topology | Validators | Regions | Use |
|---|---|---|---|
| Local devnet | 4 | 1 (localhost) | Smoke tests, dev iteration |
| Single-region testnet | 16 | 1 (single datacenter) | Component testing |
| Multi-region testnet | 16-32 | 3 (US, EU, APAC) | Realistic perf testing |
| Production-sim | 128 | 4+ (global) | Pre-mainnet validation |
Multi-region requirement is critical. Pre-pivot HotStuff testing was likely localhost or single-DC. Real conditions include:
- 50-200ms RTT between regions
- 1-3% packet loss occasionally
- Bandwidth variation
- Time clock skew
Cloud provider matrix for production-sim:
- AWS (us-east-1, eu-west-1, ap-southeast-1)
- GCP (us-central, europe-west, asia-east)
- Hetzner / Vultr / OVH (cost-optimized)
- Mix providers for cross-provider scenarios
Workload Generators
#![allow(unused)] fn main() { trait Workload { fn generate_tx(&mut self, ctx: &Context) -> Tx; fn target_tps(&self) -> u64; fn distribution(&self) -> &Distribution; } }
Concrete workloads:
- TransferWorkload: simple A→B transfers; baseline
- ContractWorkload: realistic WASM contract interactions
- EncryptedSwapWorkload: ~80% encrypted (worst-case for decryption)
- NFTMintBurstWorkload: ramps from idle to a high burst and back over 60s
- MixedWorkload: 70% transfers / 15% contracts / 10% encrypted / 5% complex
Workload realism:
- Real FALCON sig generation (not pre-computed)
- Real Kyber encryption (not pre-computed)
- Variable tx sizes (not all minimum)
- Account hot-spotting (some accounts get more traffic — tests parallel execution)
Metrics Collected (Continuous)
TPS Metrics
tps_sustained— average over last 60stps_burst— peak sustained over 10stps_pending— txs in mempool / queued
Latency Metrics (Percentiles p50, p90, p99, p99.9)
tx_submission_to_finality— end-to-endtx_in_batch_latency— submit → in batchbatch_to_vertex_latency— batch → referenced by vertexvertex_to_commit_latency— vertex → commitcommit_to_execution_latency— commit → wasmtime executeddecryption_ceremony_latency— start partial → ≥85 received
Consensus Metrics
round_advance_rate— rounds/sec per validatorvertex_certification_rate— % of vertices that get 85+ certscommit_success_rate— % of rounds where commit firesanchor_selection_success_rate— % of anchors that have valid vertex
Resource Utilization (Per Validator)
cpu_usage_pct— total CPUcpu_per_subsystem— consensus / wasmtime / network / IOmemory_resident_mb/memory_heap_mbdisk_read_iops/disk_write_iops/disk_used_gbnetwork_in_mbps/network_out_mbpsopen_file_descriptors/tcp_connections
State Metrics
jmt_depth_max/jmt_depth_avgstate_root_compute_ms(per commit)state_growth_per_hour_mb
Network Metrics
peer_countpeer_score_distributionmessages_per_second(by type)bandwidth_per_message_typefailed_message_rate
Validator-Specific
slashing_events_per_epochdkg_ceremony_time_msepoch_transition_time_ms
Soak Test Schedule
| Test | Duration | Frequency |
|---|---|---|
| Smoke test | 5 min | Every commit (CI) |
| Short soak test | 1 hour | Daily |
| Standard soak test | 4 hours | Weekly |
| Extended soak test | 24 hours | Pre-release |
| Pre-launch soak test | 7 days | Before mainnet only |
Pass criteria for soak tests:
- TPS within 5% of starting value over 4 hours
- p99 latency within 20% of starting value
- Memory growth < 100 MB/hour (excluding state)
- No consensus stalls > 5 seconds
- No new "halt" events (other than scripted chaos)
Chaos Scenarios
#![allow(unused)] fn main() { trait ChaosScenario { fn name(&self) -> &str; fn execute(&self, network: &mut TestNetwork) -> ChaosResult; } }
- ValidatorRestart: random validator restarts every 5 min
- NetworkPartition: split 30% of validators for 5 min
- SlowPeer: inject 500ms latency on some peers
- BadActor: validator equivocates, sends bad sigs, attacks
- BandwidthConstraint: cap one validator at 100 Mbps
- ClockSkew: skew validator clocks by up to 5s
Mandatory Pre-Mainnet Tests
All must pass with publishable evidence before any TPS claim:
| Test | Pass Criteria |
|---|---|
| Steady-state at v1 target | 4 hours at the v1 throughput target, p99 <1s, no stalls |
| Burst above target | 60s burst absorbed, queue drains in 5 min |
| Validator restart loop | 24h with restarts every 5 min, no stall |
| Network partition | 30% partition for 5 min, both recover, no fork |
| DKG under load | Epoch transition at the v1 throughput target, no commit stall |
| State sync under load | New node joins under sustained load, syncs in <1 hour |
| Slashing under load | Equivocation slashed within 1 epoch |
| 7-day soak test | Sustained load for 7 days, no memory leak, no drift |
| Encrypted tx mix | 30% encrypted at the v1 throughput target, decrypt latency <500ms |
| Modest hardware | Single committee validator on 1 Gbps, 8c/16GB |
Honest Reporting Discipline
The publishing discipline:
- Publish only what the harness measures under sustained, production-realistic conditions.
- Never lab extrapolations, microbenchmark peaks, or single-machine numbers where multi-region is the relevant scope.
- Aspirational figures are labelled "production validation pending" and carry no concrete number.
Publication format:
"Pyde sustained [harness-measured] TPS over a 4-hour test on a 16-validator multi-region testnet (US-East, EU-West, AP-Southeast), with median finality of 480ms and p99 of 950ms. Workload: 70% transfers, 15% contract calls, 10% encrypted, 5% complex. Test methodology and raw data available at
pyde.network/perf/{run-id}."
Specific numbers, methodology referenced, reproducible. NOT "Pyde supports [huge number] TPS" with no caveats.
Public Dashboard Structure
pyde.network/perf
├── Current Metrics
│ ├── Sustained TPS (last 7 days)
│ ├── p50, p99 latency
│ ├── Validator count + uptime
│ └── Test network conditions
├── Soak-Test History
│ ├── 4h, 24h, 7d soak-test results
│ ├── Pass/fail per scenario
│ └── Regression trend lines
└── Methodology
├── Test topology
├── Workload composition
├── Hardware specs
└── How to reproduce
Build Effort
| Component | Effort |
|---|---|
| Basic harness skeleton + workload generators | ~2 weeks |
| Multi-region deployment automation | ~1 week |
| Metrics collection + Prometheus integration | ~1 week |
| Chaos testing scenarios | ~2 weeks |
| Long-duration soak-test runners | ~1 week |
| Reporting + dashboard | ~1 week |
| Total minimum viable harness | ~8 weeks of focused engineering |
In practice, with competing priorities across the rest of the protocol, this sequences across a multi-month window rather than running back-to-back.
Cloud Cost
- 16-validator multi-region testnet: ~$300/month sustained
- Pre-mainnet 128-validator production-sim: ~$2500/month
- Run as needed; don't keep production-sim running continuously
The Key Principle
Build harness BEFORE making any TPS claims externally. The harness IS the evidence. Without it, claims are aspirational. With it, claims are defensible.
This is the HotStuff lesson. Don't skip.
References
- Honest throughput targets: see WHITEPAPER.md §11
- Chaos integration with failure scenarios: see FAILURE_SCENARIOS.md
Document version: 0.1
License: See repository root
Pyde Parachain Design
Version 0.1
This is the canonical design specification for Pyde's parachain framework. Chapter 13 is the narrative overview; this document is the deeper mechanics, the design rationale, and the surface that future PPIPs (Pyde Parachain Improvement Proposals) extend.
1. Scope and framing
A Pyde parachain is an on-chain WASM module with an extended host-function allowlist, a private state subtree, and its own validator committee selected from the main Pyde committee. It is not a slot-auction model (Polkadot-style), not a separate operator network running off-chain, and not a cross-chain bridge to a foreign L1.
The word "parachain" is overloaded in the L1 ecosystem. In Pyde:
| Term | Meaning |
|---|---|
| Smart contract | A WASM module deployed via otigen that shares Pyde's general state space and runs on the main executor. |
| Parachain | A WASM module deployed via otigen with type = "parachain", granted: (a) its own state subtree partitioned under PIP-2 clustering by parachain_id[..16], (b) extended host-function access (cross-parachain messaging, threshold-crypto access, governance hooks), (c) its own validator committee (a subset of Pyde's main committee that opts in at deploy time), and (d) its own upgrade governance. |
| Cross-chain bridge | Infrastructure that ferries proofs between Pyde and a foreign L1 (Ethereum, Bitcoin). Out of scope here — see Chapter 13 §13.2-§13.3, §13.6. |
What ships at v1: registration, deployment, state partitioning, cross-parachain messaging, version history retention, and the host-function ABI surface. Deferred to v2 (per OTIGEN_BINARY_SPEC §8.2 / §8.3): governance-cert-gated runtime upgrades (v1 parachains are pinned to a fixed runtime) and chain-side pause / unpause / kill tx types. v1 parachains use the same patterns as v1 contracts for those operations — the proxy + delegate_call pattern for upgrade, and author-declared paused/killed booleans in [state] for pause / kill.
2. Why this model
Three design choices distinguish Pyde's parachains from the alternatives:
- No slot auctions. Slot auctions concentrate parachain rights in deep-pocketed operators, creating political and centralization risk. Pyde parachains are deployed by name registration (ENS-style, see §4) with predictable costs.
- Equal-power validator voting. Each registered parachain validator gets one vote on upgrades, NOT stake-weighted (see §7). This is consistent with Pyde's "uniform random + min stake, no stake weighting" committee philosophy and prevents large-stake validators from dominating parachain decisions.
- No maintained per-language SDK. Pyde provides the Host Function ABI specification, the
otigenCLI (top-levelinit / build / deploy / upgrade / pause / unpause / kill / call / inspect / test / verify / console / devnet), and canonical example projects. Parachains use--type parachainatotigen inittime and the same deploy / lifecycle commands as contracts, with parachain-specific behavior gated on thecontract_typecarried in the deploy tx data. Authors compile their own WASM in any wasm32-target language and declare host imports manually. See §11.
3. Architecture overview
A parachain at v1 consists of:
┌─────────────────────────────────────────────────────────────────┐
│ Parachain account (on-chain) │
│ │
│ parachain_id: [u8; 32] (derived from name; see §4) │
│ name: String ("chainlink", "uniswap", etc.) │
│ owner: Address │
│ current_version: u32 │
│ versions: Vec<ParachainVersionRecord> (full history) │
│ state_root: [u8; 32] (subtree root) │
│ config: ParachainConfig │
│ status: Active | Paused | Killed │
└─────────────────────────────────────────────────────────────────┘
│
│ partitions
▼
┌─────────────────────────────────────────────────────────────────┐
│ Parachain state subtree (PIP-2 clustered under jmt_cf) │
│ │
│ slot_hash format: │
│ parachain_id[..16] || Hash(slot_namespace || ...)[..16] │
│ │
│ → entire parachain's state lives in a contiguous JMT subtree │
│ → snapshot, range scan, cross-parachain proof all efficient │
└─────────────────────────────────────────────────────────────────┘
│
│ managed by
▼
┌─────────────────────────────────────────────────────────────────┐
│ Parachain validator committee │
│ │
│ - Subset of Pyde's main 128-validator committee │
│ - Opted in at deploy time (or at upgrade) │
│ - Configurable size: min 7, default 21 │
│ - Equal-power voting (1 validator = 1 vote) │
│ - Per-parachain consensus preset (simple_bft / threshold / opt) │
└─────────────────────────────────────────────────────────────────┘
│
│ executes
▼
┌─────────────────────────────────────────────────────────────────┐
│ Parachain WASM (wasmtime, Cranelift AOT) │
│ │
│ - Imports: only functions from the parachain ABI allowlist │
│ (validated at deploy time) │
│ - Linear memory: 64 MB cap │
│ - Fuel: derived from tx.gas_limit │
│ - Deterministic feature subset (no threads, no SIMD floats, …) │
└─────────────────────────────────────────────────────────────────┘
4. Parachain ID derivation
parachain_id = Poseidon2("pyde-parachain:" || name_bytes)
Names are globally unique, ENS-style. 1-32 chars, single-letter allowed. First-come-first-served at registration with yearly renewal + grace period (see Chapter 11 for the full naming model).
Why prefix the hash with "pyde-parachain:" — to keep the parachain namespace disjoint from the contract namespace and the account namespace. A contract named chainlink and a parachain named chainlink would otherwise collide on Poseidon2(name). The prefix forces them into different parachain_id and contract_address values even when their human-readable names are identical.
Why 32 bytes for the full ID — see [memory: address-naming-collision]. Pyde uses full 32-byte addresses everywhere (no truncation). The first 16 bytes are used by PIP-2 clustering (§5); the full 32 bytes are the canonical identifier in receipts, events, and cross-parachain messages.
Collision risk: with 2^128 possible 16-byte clustering prefixes, the birthday bound is ~2^64 names before a clustering collision becomes likely. Pyde additionally enforces uniqueness at registration time — the on-chain name registry rejects any name whose Poseidon2 hash matches an existing parachain's. PIP-2 collision risk is effectively zero.
5. State partitioning (PIP-2)
All of a parachain's state lives in a contiguous JMT subtree. The slot_hash format:
slot_hash[0..16] = parachain_id[..16] (clustering prefix)
slot_hash[16..32] = Hash(slot_namespace || key)[..16]
Where slot_namespace is the parachain's internal namespace (e.g., "balances", "orders", "config") and key is the slot-specific key bytes.
Benefits inherited from PIP-2:
- Snapshot efficiency. Snapshotting a single parachain is a contiguous JMT subtree walk. No filtering, no global scan.
- Range scan efficiency. RocksDB's clustered key layout means the parachain's data lives in adjacent SST blocks. Hot parachains stay hot in the block cache.
- Per-parachain state-root. The subtree's root hash is naturally available; light clients can verify proofs against per-parachain roots without verifying the global root.
- Cross-parachain proofs. Parachain A can include a JMT inclusion proof from parachain B's state in its own state transitions — the verifier only needs B's subtree root, not B's full state.
The clustering applies recursively: within a parachain's namespace, the slot_namespace prefix further clusters related keys (all balances together, all orders together).
6. Lifecycle
REGISTERING
│
owner submits │ RegisterParachainTx
deploy fee + │ with name + WASM + config
owner deposit │
▼
ACTIVE
/ \
/ \ governance vote
owner / \ to upgrade
pause ▼ ▼
PAUSED → UPGRADING
│ │
│ owner │ new version activates
│ unpause │ at wave N + grace_period
│ │
▼ ▼
ACTIVE ACTIVE (new version)
kill (owner-only, irreversible)
│
▼
KILLED
6.1 Registration
RegisterParachainTx {
name: String // 1-32 chars
initial_wasm: WasmBytes // ≤ 4 MB
config: ParachainConfig
owner: Address
validator_set: Vec<ValidatorPubkey> // opt-in committee members
deploy_fee_paid: u128
owner_deposit: u128
}
Validations at registration:
- Name is well-formed (1-32 chars, alphanumeric + hyphens).
- Name is not already registered (uniqueness check via registry).
- WASM module is well-formed and instantiable under Pyde's deterministic wasmtime config.
- WASM imports only functions in the parachain ABI allowlist (§11).
validator_set⊆ current main committee; size ≥config.min_validators.- Owner has paid the deploy fee + has the owner deposit available.
- Config is internally consistent (e.g., quorum_threshold ≤ validator_set.len()).
On success: parachain_id is derived (§4), the parachain account is initialized with version 0 (the initial WASM), state subtree root is set to empty (Poseidon2 of empty tree), status is Active.
6.2 Upgrade
v1 (proxy pattern). v1 parachains are pinned to a fixed runtime; chain-side runtime upgrades are deferred to v2 per OTIGEN_BINARY_SPEC §8.2. v1 authors compose upgradability into the parachain itself the same way contracts do: deploy a proxy module that delegate_calls into a concrete implementation, then deploy a new implementation and re-point the proxy. The proxy pattern lives in user-space WASM, so the chain's runtime image stays fixed.
v2 (chain-side UpgradeParachainTx + governance certs). The target shape is a chain-side tx the parachain's validator committee threshold-signs after a §7 vote passes:
UpgradeParachainTx {
parachain_id: [u8; 32]
new_wasm: WasmBytes
new_config: ParachainConfig
proposal_id: ProposalId
vote_certs: Vec<FalconSig> // ≥ quorum from §7
threshold_sig: ThresholdSig // parachain committee threshold-signed
}
On successful submission (v2):
- The transaction includes the upgrade in the next wave's commit.
- A
ParachainVersionRecordis appended toversionswithactivated_at_wave = current_wave + grace_period(default 100 waves ≈ 50s at 500ms/wave). - The parachain's
current_versionis bumped at the activation wave. - ALL parachain peers + relay nodes simultaneously swap the wasmtime
Moduleinstance. Old instance is discarded, new active. Module is pre-compiled and cached so the swap is sub-millisecond. - First N waves post-activation: nodes verify their local execution matches consensus. Mismatch = halt + alert (indicates corrupted upgrade or compile-time variation).
6.3 Pause / Unpause
v1 (author-declared booleans). Same pattern as v1 contracts per OTIGEN_BINARY_SPEC §8.3: authors declare paused: bool in [state] and gate sensitive entry-point bodies on it. Owner toggles the flag with an entry-attributed setter. No chain-side PauseParachainTx tx variant exists in v1.
v2 (PauseParachainTx / UnpauseParachainTx). Chain-side pause flips ParachainStatus to Paused; ingress rejects new transactions while in-flight transactions complete, and state is preserved. Owner can resume via UnpauseParachainTx. No governance vote needed for pause/unpause — operational lifecycle, not a protocol-level decision.
6.4 Kill
v1 (author-declared boolean). Same pattern as pause: killed: bool in [state], entry-point bodies revert when set. No chain-side KillParachainTx in v1; the owner deposit return + state pruning + name-reuse grace are v2 mechanics.
v2 (KillParachainTx, irreversible). Marks the parachain Killed. After kill:
- New transactions are rejected.
- The owner deposit is returned to the owner (minus a cleanup fee).
- The parachain's state subtree is retained on-chain for
STATE_RETENTION_WAVES(default ~1 year), then pruned by archive nodes. - The name remains in the registry but cannot be re-registered for
NAME_REUSE_GRACE(default 1 year) to prevent confusion.
6.5 Version history retention — never discarded
#![allow(unused)] fn main() { pub struct ParachainAccount { pub name: String, pub parachain_id: [u8; 32], pub current_version: u32, pub versions: Vec<ParachainVersionRecord>, // FULL HISTORY, ordered pub balance: u128, pub config: ParachainConfig, pub state_root: [u8; 32], pub owner: Address, pub status: ParachainStatus, } pub struct ParachainVersionRecord { pub version: u32, pub wasm_hash: [u8; 32], pub wasm_blob_ref: ContentAddress, pub config_snapshot: ParachainConfig, pub activated_at_wave: WaveId, pub deactivated_at_wave: Option<WaveId>, pub upgrade_proposal_id: ProposalId, pub upgrade_vote_certs: Vec<FalconSig>, pub upgrade_committee_threshold_sig: ThresholdSig, } }
Storage tiering: the last 5 versions store WASM bytes on-chain. Older versions store only wasm_hash + wasm_blob_ref pointing to off-chain content-addressed storage (IPFS-like). Metadata (hashes, configs, signatures) stays on-chain forever. Authors are expected to maintain off-chain mirrors of historical builds; archive nodes also pin them.
Why retain forever: every parachain-touching tx receipt includes (parachain_id, parachain_version, wasm_hash). Wave-commit records include a manifest of parachain versions active during that wave. Replay nodes (during state sync verification, slashing-evidence replay, or historical queries) use these to fetch the exact WASM binary that originally executed each tx. Discarding history would make replay impossible.
7. Governance: equal-power voting
Parachain validators: one validator, one vote
Quorum: configurable per parachain (default 2/3 of validators must vote)
Threshold: 2/3 of voters say YES to pass
This is NOT stake-weighted. Each registered parachain validator gets exactly one vote on upgrade proposals, regardless of their stake size. The rationale (which mirrors Pyde's main-committee philosophy):
- Stake-weighting concentrates governance power in deep-pocketed validators.
- Equal-power voting is consistent with the anti-plutocracy stance baked into committee selection (see WHITEPAPER §5.5).
- Coalitions form on merit and operational reliability, not capital.
The vote flow:
- Proposal submission. Anyone can submit an
UpgradeProposalTxcontaining the new WASM + new config. The proposal enters aPendingstate with a public discussion period (default: 7 days). - Voting window. Each parachain validator can submit a
VoteTxwith{proposal_id, vote: yes|no|abstain, sig: FalconSig}. Voting is open for the configured window (default: 3 days after the discussion period). - Tally. After the voting window closes, vote certs are collected. If quorum (2/3 of validators must vote) is met and threshold (2/3 of voters say YES) is hit, the proposal advances to
Approved. - Threshold ceremony. The parachain's validator committee runs a threshold-signing ceremony over the proposal hash. The output is the
upgrade_committee_threshold_sigthat goes into the version record. - Activation. An
UpgradeParachainTxincludes the vote certs + threshold sig + new WASM + scheduled activation wave. After the grace period, the upgrade activates as described in §6.2.
If quorum is not met or threshold is not hit, the proposal is Rejected and cannot be re-submitted unchanged for PROPOSAL_COOLDOWN (default: 30 days).
8. Capability model (host-function allowlist)
Parachain WASM is sandboxed; host functions are the only escape. Pyde exposes a fixed allowlist:
EXPOSED (parachain ABI):
storage:
parachain_storage_read(key_ptr, key_len, out_ptr, out_len_ptr) -> i32
parachain_storage_write(key_ptr, key_len, val_ptr, val_len) -> i32
parachain_storage_delete(key_ptr, key_len) -> i32
events:
parachain_emit_event(topic_ptr, topic_len, data_ptr, data_len) -> i32
context:
parachain_get_caller(out_ptr) -> i32
parachain_get_wave_id() -> u64
parachain_get_parachain_id(out_ptr) -> i32
cross-parachain messaging (rate-limited):
parachain_send_xparachain_message(target_id_ptr, msg_ptr, msg_len, callback_spec_ptr) -> i32
threshold crypto (optional):
threshold_decrypt(ciphertext_ptr, ciphertext_len, out_ptr, out_len_ptr) -> i32
threshold_encrypt(plaintext_ptr, plaintext_len, out_ptr, out_len_ptr) -> i32
hashing primitives:
hash_keccak256(in_ptr, in_len, out_ptr) -> i32
hash_blake3(in_ptr, in_len, out_ptr) -> i32
hash_poseidon2(in_ptr, in_len, out_ptr) -> i32
explicit gas metering:
consume_gas(units: u64) -> i32
EXPLICITLY FORBIDDEN:
network calls (any kind) — non-deterministic
file/disk access — non-deterministic + capability escape
system clock — non-deterministic; use wave_timestamp / wave_id instead
non-deterministic entropy — non-deterministic; use VRF beacon via host fn
direct RocksDB access — must route through parachain_storage_*
WASM threads — non-deterministic by definition
non-deterministic SIMD / float ops — determinism risk
WASI — not allowed (whole interface forbidden)
Deploy-time validation rejects any .wasm whose imports reference functions outside the allowlist. Hard-enforced — there is no opt-out.
9. Cross-parachain messaging
Parachains call each other via parachain_send_xparachain_message. Mechanics:
send_xparachain_message(
target_id: [u8; 32], // target parachain
msg: bytes, // payload (parachain-defined format)
callback_spec: {
callback_fn: String, // function on the calling parachain
max_callback_gas: u64,
timeout_waves: u64, // give up after this many waves
}
) -> XCallId
The flow:
- Send. Calling parachain's WASM invokes the host fn. A
XCallMessageis recorded in the calling parachain's outgoing-queue state. The current wave's commit records the outgoing message. - Threshold sig. The calling parachain's validator committee threshold-signs the outgoing message (deferred to the next wave's vertex piggybacking; one threshold sig per outgoing message).
- Route. Pyde's main consensus relays the message: every wave commit, the engine scans all outgoing-queue diffs and produces
XCallDeliveryTxtransactions targeting the destination parachain. - Verify on receive. The target parachain's validator committee verifies the incoming threshold sig against the source committee's pubkeys (which it knows from the on-chain registry). On verify failure, the message is dropped + logged (no callback fires).
- Execute. On verify success, the target parachain's WASM is invoked with the message payload as input. The target executes, may emit events, may write state.
- Callback. A return value (or timeout) is recorded in the target's outgoing-queue, routed back to the original caller, and that caller's
callback_fnis invoked with the result + the callback context.
Rate limit: each parachain has a configurable budget of outgoing messages per wave (default: 64). Exceeding the budget causes the host fn to trap with XCallRateLimited.
Callback context is preserved across the round-trip:
callback_id unique per call
original_caller address that initiated the original tx
original_fn function that issued the cross-call
original_args_hash hash of original args (full args retrievable from chain log)
issued_at_wave when the call was issued
target_id which parachain was called
This is the same callback context model as cross_call (Chapter 13 §13.4), just specialized for parachain-to-parachain.
10. No-SDK approach
Pyde does not ship a maintained per-language SDK for parachain development. The rationale (locked in 2026-05-21 session):
- A solo-founder's bandwidth cannot maintain language-specific SDKs alongside the core protocol — that is months of work per year per language.
- The WASM ecosystem already has mature toolchains for Rust, AssemblyScript, Go (TinyGo), C/C++, Zig.
- Per-language SDKs create version-skew between SDK and ABI; better to have a single ABI doc that languages adapt to (and that the language-community can wrap on their own time).
- Ethereum's ecosystem has 50+ community Web3 libraries — none "official." Healthy decentralized tooling emerges this way.
What Pyde provides:
- Host Function ABI Specification — a ~10-page document covering names, signatures, memory layout conventions, gas cost table per host function, ABI versioning rules.
- The
otigenCLI — the same top-level binary used for contracts. Parachains useotigen init <name> --type parachainto scaffold with the §8 imports surface;otigen buildpackages the bundle (bundle carriescontract_type = Parachain);otigen deployships the bundle on-chain (same subcommand as contracts; parachain-specific validations gate on the bundle'scontract_type). v1 parachain lifecycle uses the same surfaces as contracts — proxy-pattern upgrade, author-declaredpaused/killedbooleans. v2 will add governance-cert-gatedotigen upgrade --parachainper OTIGEN_BINARY_SPEC §3.4. The canonical surface lives in OTIGEN_BINARY_SPEC §3.3 / §3.4 / §4.10. - On-chain parachain registry — single source of truth for config + WASM bytes + version history.
- Hardcoded bootstrap nodes — peer discovery; no DHT (see Network Protocol).
- Slashing preset menu — minimal / standard / strict; authors pick at deploy time.
- Canonical example parachains (NOT maintained SDKs — just starter projects authors can copy and modify):
hello-world-parachain(Rust)hello-world-parachain(AssemblyScript)hello-world-parachain(Go/TinyGo)
What authors provide:
- Their compiled
.wasm(any wasm32-target language). - A
parachain.tomlconfig file declaring state schema, consensus preset, slashing preset, allowed host imports. - Manual
extern "C"(or language-equivalent) import declarations for host functions they call.
11. ZK-readiness path baked in
Authors are instructed (in the ABI doc) to use the deterministic WASM subset:
- No floats outside canonical NaN.
- No threads.
- No non-deterministic SIMD.
- No mutable globals (only immutable globals or per-instance memory).
This keeps WASM bytecode amenable to future zk-WASM proving (~2-3 years out per current research trajectory). Authors who comply now will be ZK-ready by default later. Non-deterministic features are already blocked by Pyde's wasmtime config (deploy validator rejects them), so compliance is automatic.
12. Slashing presets
Parachains pick from a three-tier menu at deploy time:
| Preset | Equivocation | Bad state root | Liveness (offline) |
|---|---|---|---|
minimal | 5% | 5% | 0.5%/epoch |
standard | 25% | 10% | 1%/epoch |
strict | 50% | 25% | 2%/epoch |
The preset applies to that parachain's validator committee only — not to those validators' main-committee stake. Main-committee slashing (see SLASHING.md) is separate and additive.
Why a preset menu rather than free parameters: small parachain teams should not have to make slashing-economics decisions. The presets are sane defaults chosen by Pyde's economic model. If a parachain wants custom slashing, they can submit a PPIP to add a new preset; the existing three should cover 95% of use cases.
13. Parachain economics
PYDE is the gas token across the platform. Every parachain operation that touches state, emits events, sends cross-parachain messages, or consumes execution gas is metered in PYDE via wasmtime fuel — exactly the same as smart-contract operations. Authors pay registration fees + owner deposits in PYDE at deploy time. Validators of a parachain earn PYDE rewards via the standard inflation distribution, weighted by their committee membership and uptime.
Parachain authors can layer their own internal token economies on top (e.g., a DEX parachain might mint LP tokens; a DAO parachain might mint governance tokens) — but those are application-layer concerns, not protocol-level mechanics. The protocol charges PYDE; what the parachain charges its users is its own decision.
This keeps the gas accounting simple: one token, one fuel mechanism, uniform across smart contracts and parachains.
14. Failure modes
| Failure | Detection | Recovery |
|---|---|---|
| Parachain WASM enters infinite loop | Fuel exhausted → trap | Tx fails; gas charged; state rolled back |
| Cross-parachain message verify fails | Target committee rejects | Message dropped + logged; no callback fires |
| Cross-parachain message timeout | timeout_waves exceeded | Callback fires with XCallTimeout error |
| Parachain committee falls below quorum | Wave-commit fails for parachain txs | Parachain enters LimpMode; only no-state txs land until quorum restored |
| Bad WASM upgrade (deterministic divergence) | First N post-activation waves see local-vs-consensus mismatch | Hard halt + alert; manual emergency rollback via main governance |
| State subtree corruption | JMT root mismatch on snapshot verification | Cross-verify with peers; re-sync the parachain's subtree from snapshot |
| Name registry race (two parties register same name simultaneously) | Atomic registry check rejects later one | First confirmed at wave-commit wins; later one refunded |
15. v2 directions
Tracked but explicitly deferred to v2 or later:
- ZK-aggregated FALCON signature verification for parachain committees — the path to massively higher throughput. ~95% of the prerequisite work (dual-hash JMT, Poseidon2 state root) is done at v1; the aggregation circuit + verifier is v2 work.
- Adaptive validator-set rotation per parachain — currently the validator set is fixed at deploy and changes via governance. v2 may allow continuous rotation based on uptime / stake.
- Multi-WASM execution within one parachain — currently one parachain = one WASM module. v2 could allow modular parachains with hot-swappable components.
- First-class light-client parachain bootstrap — currently new parachain validators sync the full subtree. v2 could ship per-parachain light-client mode for resource-constrained validators.
16. References
- Narrative overview: Chapter 13
- Account model + naming: Chapter 11
- State model + PIP-2 clustering: Chapter 4
- Execution layer + WASM: Chapter 3
- Slashing: SLASHING.md
- Threat model: THREAT_MODEL.md
- Network protocol: NETWORK_PROTOCOL.md
Document version: 0.1
License: See repository root
Pyde Block-STM Execution Layer
Version 0.2 — v1 model locked as uniform Block-STM; access list is prefetch hint only; the hybrid "static groups + Block-STM fallback" framing in earlier book drafts is stale + superseded as of 2026-06-12.
How transactions in a committed wave execute on a validator. v1 mainnet ships parallel execution via a Block-STM scheduler — every wave's txs run optimistically in parallel, conflicts are detected via multi-version concurrency control, and the final state is deterministic across validators.
The wire protocol, gas semantics, and commit_wave interface are all unchanged from a hypothetical serial implementation. The parallelism lives entirely inside the executor crate; chain rules don't depend on it.
Goals
- Parallel within a wave — every tx in a wave runs concurrently on a
num_cpus-wide rayon pool. Throughput scales with hardware. - Deterministic final state — every validator that applies the same
walked_subdagproduces the same JMT root + the same receipt set. Per-tx execution attempt order can differ across validators or across re-runs; only the committed final state has to match. - Gas charged once — speculative re-executions are free. Authors pay for the successful attempt only.
- Backwards-compatible interface —
StateMutator::commit_wave(walked_subdag) -> WaveCommitInputsis the only entry point. Switching between serial and parallel impls is a code-level swap, not a chain fork. - Access list = prefetch hint, never used for scheduling. Wallets attach a
Tx.access_listproduced bypyde_simulateTransactionso the scheduler can warm the dashmap (PIP-4 cache) via PIP-3 multiget prefetch before execution starts. The list never partitions the wave, never decides which tx runs where, and never affects correctness. Block-STM owns scheduling + safety; the access list owns warm-cache performance. If the list is wrong, prefetch misses some slots — execution still produces the correct deterministic result.
Non-Goals
- Speculative across waves. Cross-wave reordering is out. Each wave's
walked_subdagdefines a strict canonical order; tx_index is the sole tiebreaker. - Strict trace determinism. Re-runs do not have to produce identical per-tx attempt traces. Only the committed receipt + state root must match. Aptos / Sui made the same call.
- Eliminating sequential commit semantics. The conceptual model is still "execute these N txs in canonical order against the prior wave's state, produce a new state." Parallelism is a performance technique under that model, not a re-design of the consensus contract.
Where it Lives
A new crate, pyde-engine-parallel-exec, depending on:
pyde-engine-state(JMT, slot APIs,StateMutatortrait)pyde-engine-wasm-exec(per-tx wasmtime adapter)pyde-engine-types(Tx, AccessList, WaveCommitInputs)rayon(work-stealing CPU pool)
The crate exposes one type:
#![allow(unused)] fn main() { pub struct BlockStmExecutor { pool: rayon::ThreadPool, // owned wasmtime Engine + per-thread Linker cache (see WASM ABI spec) } impl BlockStmExecutor { pub fn new(num_threads: usize) -> Self; pub fn execute_wave( &self, walked_subdag: &[Tx], prior_state: Arc<StateStore>, ) -> WaveCommitInputs; } }
Validators construct one BlockStmExecutor at boot and reuse it across every wave. The pool is sized to num_cpus() by default; pyde validator --executor-threads N overrides for benchmarks.
The serial fallback (SerialExecutor) is kept in wasm-exec as a differential-testing oracle. It's compiled in cfg(test) only.
Core Data Structures
MvccLayer
The multi-version store. Buffers every per-tx-attempt write; reads scan backwards from the calling tx's index for the most recent committed write.
#![allow(unused)] fn main() { pub struct MvccLayer { // Per-slot version history. BTreeMap key is (tx_index, attempt). // Reads at tx_index T scan for the largest key whose tx_index < T, // ignoring later attempts of the same earlier tx. versions: DashMap<SlotHash, BTreeMap<VersionKey, Value>>, // Genesis fallback — the JMT view at the start of the wave. base: Arc<StateStore>, } #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct VersionKey { pub tx_index: u32, pub attempt: u32, } impl MvccLayer { /// Read the value at `slot` from the perspective of tx `at_index`. /// Returns the most recent committed write from any tx with /// `tx_index < at_index`; falls through to `base` if no in-wave write. pub fn read(&self, slot: SlotHash, at_index: u32) -> Option<Value>; /// Record a write by tx `(at_index, attempt)`. pub fn write(&self, slot: SlotHash, at_index: u32, attempt: u32, value: Value); /// Drop every write recorded by `(at_index, attempt)`. Called /// when the tx is aborted + re-incarnated at a higher attempt. pub fn invalidate(&self, at_index: u32, attempt: u32); /// Final wave-commit snapshot: for each slot, take the highest /// tx_index's last committed write. Flushes to the underlying JMT. pub fn finalize(self) -> JmtFlush; } }
DashMap for the outer map gives us lock-free contention on disjoint slots. The per-slot BTreeMap is wrapped in a fine-grained lock; reads and writes to the same slot serialize.
AccessTracker
Per tx-attempt, the set of (read_slot, observed_version) and (write_slot, value_hash) pairs. Drives the validation pass.
#![allow(unused)] fn main() { pub struct AccessTracker { pub reads: Vec<(SlotHash, Option<VersionKey>)>, pub writes: Vec<SlotHash>, } impl AccessTracker { /// Returns true iff every observed_version in `reads` is still /// the most-recent-prior-version at the calling tx's index. pub fn validate(&self, layer: &MvccLayer, at_index: u32) -> bool; } }
Option<VersionKey> covers reads that fell through to the base JMT (no in-wave write at the time).
Scheduler
The dispatch + retry loop. Holds the per-tx state machine and the next-up queues.
#![allow(unused)] fn main() { pub struct Scheduler { txs: Vec<TxState>, // FIFO of tx_index values ready to execute (next attempt). execute_queue: Mutex<VecDeque<u32>>, // FIFO of tx_index values whose latest attempt finished + needs validation. validate_queue: Mutex<VecDeque<u32>>, done_count: AtomicU32, } pub struct TxState { pub tx_index: u32, pub status: AtomicU8, // Pending | Executing | Validating | Validated | Aborted pub attempt: AtomicU32, // Latest AccessTracker for this tx, written by the executor + read by the validator. pub tracker: ArcSwap<Option<AccessTracker>>, } }
The scheduler exposes next_task() -> Task:
#![allow(unused)] fn main() { pub enum Task { Execute { tx_index: u32, attempt: u32 }, Validate { tx_index: u32, attempt: u32 }, Done, } }
Workers pull from execute_queue first (fast path: txs flowing forward), then validate_queue. The pool exits when done_count == N.
Algorithm
The wave's canonical order is the walked_subdag's included_txs. tx_index assigned 0..N at the top of execute_wave.
1. Initial enqueue
Push every tx_index ∈ 0..N onto execute_queue. Initial state for each tx: attempt = 0, status = Pending.
2. Optimistic execute (rayon workers)
Each worker pulls a Task::Execute { tx_index, attempt } and runs:
#![allow(unused)] fn main() { let mut store = wasmtime::Store::new(&engine, MvccContext { layer, tx_index, attempt }); let mut tracker = AccessTracker::default(); let outcome = wasm_exec.execute(&mut store, &tx, &mut tracker)?; // Writes already landed in MvccLayer via the host-fn shim during execute. // `tracker` carries the read + write record. scheduler.set_tracker(tx_index, attempt, tracker); scheduler.transition(tx_index, Executing -> Validating); scheduler.validate_queue.push_back(tx_index); }
The wasmtime Store's Data is MvccContext. Every host-fn read/write goes through the MvccLayer at the calling (tx_index, attempt). Host-fn semantics, gas costs, and FALCON-sig verification are unchanged from the existing wasm-exec adapter.
3. Validation (rayon workers)
Workers also pull Task::Validate { tx_index, attempt }:
#![allow(unused)] fn main() { let tracker = scheduler.tracker(tx_index, attempt); if tracker.validate(&layer, tx_index) { scheduler.transition(tx_index, Validating -> Validated); scheduler.done_count.fetch_add(1, Ordering::AcqRel); } else { // Conflict: a tx with lower tx_index wrote to a slot we read, // and our observed_version no longer matches. layer.invalidate(tx_index, attempt); scheduler.set_attempt(tx_index, attempt + 1); scheduler.transition(tx_index, Validating -> Pending); scheduler.execute_queue.push_back(tx_index); } }
A conflict re-incarnates the tx at attempt + 1. The OLD attempt's writes are dropped from the MvccLayer.
When validate(tx_index) finds a conflict, a CASCADE rule fires: every tx with tx_index' > tx_index whose tracker.reads includes any slot in this tx's write set must also re-validate. The cheap way to handle this: maintain a dependents[tx_index] map; when tx fails validation, mark its dependents Validating -> Pending (they'll re-execute against the new lower-version writes).
Aptos's published bound: O(N) re-executions in pathological cases. Real-world workloads typically reach fixpoint in 1-2 passes.
4. Finalize
When done_count == N:
- For every slot ever written, take the highest
tx_index's last write. That's the canonical value. - Receipts are written in canonical tx_index order, using each tx's final-attempt events + return_data + gas_used.
MvccLayer::finalize()flushes the canonical-value set to the JMT in oneStateCommitter::commit_batchcall.
Access List as Prefetch Hint
The access list never partitions the wave, never schedules anything, never affects correctness. Block-STM is uniform: every tx runs through optimistic-execute + MVCC validate regardless of whether it declared a list. The list exists for ONE reason — to warm Pyde's PIP-4 dashmap cache via PIP-3 multiget prefetch before execution starts, so the wasmtime sload host fn hits an in-memory HashMap instead of going to RocksDB.
Wire format
Tx.access_list: Vec<AccessListItem> is already in the types crate:
#![allow(unused)] fn main() { pub struct AccessListItem { pub addr: Address, pub slots: Vec<SlotHash>, } }
No mode field. There's no "strict vs hint" distinction because the scheduler never uses the list for safety decisions — it's a hint about read performance, full stop. Lists that are wrong waste prefetch work but never cause a tx to fail.
Prefetch flow
1. Wave commits, canonical tx list is known.
2. Scheduler walks every tx's declared access_list and unions every
(addr, slot) pair into a single `prefetch_set`.
3. State layer issues one batched `state_cf.multi_get(prefetch_set)`
(PIP-3) — typically thousands of slots in a single RocksDB call.
4. Returned values land in the dashmap (PIP-4 write-back cache),
marked Clean (not Dirty — they're cached reads, not pending writes).
5. Block-STM workers start. Every `sload` against a prefetched slot
hits the dashmap; no disk read on the hot path.
The prefetch step is fire-and-forget — Block-STM doesn't wait for it to complete. If a worker reaches an sload for a slot the prefetch hasn't returned yet, the read falls through to state_cf.get(slot) (single RocksDB get) and lands in the dashmap on the way back. No correctness impact, just a missed warm-cache opportunity.
pyde_simulateTransaction RPC
The wallet's path to obtaining a list. Mirrors eth_estimateGas + eth_createAccessList in one call:
{
"jsonrpc": "2.0",
"method": "pyde_simulateTransaction",
"params": ["0x<borsh-encoded tx hex>"]
}
Validator runs the tx against its current state in dry-run mode (no commit, no gas charge, FALCON sig optional). Returns:
{
"gas_used": "0x5208",
"status": "Success",
"return_data": "0x...",
"access_list": [
{ "addr": "0x...", "slots": ["0x...", "0x..."] },
...
],
"events": [ ... ]
}
The wallet attaches access_list to the real tx, signs, submits via pyde_sendRawTransaction. The scheduler uses the attached list for prefetch.
What happens when the list is stale
State can move between simulate-time and finalize-time. Block-STM doesn't care:
| Case | Behavior |
|---|---|
| Tx touched only slots in declared list | Every sload hits dashmap. Fastest path. |
| Tx touched a slot outside its declared list | Missed slot reads state_cf once (single RocksDB get), lands in dashmap. ~1ms slower per missed slot. Correctness unaffected. |
| Tx writes to a slot another tx is reading | Standard Block-STM MVCC: catches the conflict at validation, re-executes the loser. Same path it would take without any access list. |
In every case the tx commits its successful attempt with the same final state. Bad lists waste prefetch bandwidth but never fail txs.
Gas + Receipts
- Gas: charged once, on the successful final attempt. Aborted attempt gas is discarded.
- Receipts: written in canonical
tx_indexorder. Each receipt carries the final attempt'sgas_used,events,return_data, andstatus. - Fee distribution: per
HOST_FN_ABI_SPEC§10.5 — the 70/20/10 burn/reward/treasury split is computed fromsuccessful_attempts.sum(fee_paid). Aborted-attempt fees do not exist.
The "no refunds in v1" rule still holds. If a tx hits a tx-level revert (not an MVCC abort — those are silent retries), gas == tx.gas_limit and value transfer is rolled back. Only MVCC re-incarnations are free.
Determinism Contract
Every validator that applies the same walked_subdag against the same prior state MUST produce:
- The same JMT root after
MvccLayer::finalize(). - The same set of receipts, in the same order, with identical
gas_used,events,return_data, andstatusfields. - The same
WaveCommitInputsreturned fromexecute_wave.
What we do NOT require:
- Identical per-tx attempt count. Validator A might Block-STM-fixpoint in 1 pass; Validator B might take 3. Both produce the same final receipts.
- Identical per-tx attempt traces. Intermediate writes + dropped versions vary by thread interleaving.
- Identical timestamps on attempts. Wall-clock isn't part of the chain hash.
The contract is enforced by:
- Property tests: random tx mixes, random rayon pool sizes, identical seeds → identical finalized state.
- Differential tests: every wave runs both
BlockStmExecutorand aSerialExecutororacle; their outputs must match bit-for-bit. - Fuzzing: AFL+ harness against
execute_wavewith mutated wave inputs.
Differential vs serial is the load-bearing check. Any divergence is a chain-fork bug; CI is configured to refuse merges when differential coverage drops.
Cross-Contract Calls
When tx A calls X.foo() which dynamically calls Y.bar(), the discovered slot reads can exceed the attached access list. Behavior:
- The reads + writes still go through
MvccLayervia the host fns — there is no separate code path. AccessTrackerrecords every slot touched, regardless of whether it was in the declared list.- Validation uses the recorded reads, not the declared list. So a tx that "exceeds" its declared list still validates correctly.
- The only consequence of exceeding the declared list is that the prefetch was incomplete: the missed slot reads from
state_cfonce (single RocksDB get) instead of hitting the dashmap. Correctness is unaffected.
State-Holding Host Functions
The host fns that read or write chain state — sload, sstore, sdelete, balance, code_size, code_hash, block_* (frozen), etc. — all route through MvccLayer in the parallel executor. Pure host fns (Blake3, FALCON verify, etc.) don't touch state and are unaffected.
The wasm-exec adapter exposes a HostFnBackend trait:
#![allow(unused)] fn main() { pub trait HostFnBackend: Send + Sync { fn sload(&self, addr: &Address, slot: &SlotHash) -> Option<Value>; fn sstore(&self, addr: &Address, slot: &SlotHash, value: Value); fn sdelete(&self, addr: &Address, slot: &SlotHash); fn balance(&self, addr: &Address) -> Balance; // ... } }
SerialExecutor implements HostFnBackend directly against AccountStore. BlockStmExecutor's MvccContext implements it against MvccLayer at the calling (tx_index, attempt).
The wasmtime Store's Data carries the backend, so no host-fn body changes.
Implementation Phases
Roughly 8 weeks of focused effort.
Phase A — Spec lock (week 1)
This document. Determinism contract, MVCC API, scheduler state machine, RPC shape. No code.
Phase B — Skeleton + MVCC (weeks 2-3)
- New crate
pyde-engine-parallel-exec. MvccLayerwith serial single-thread access. Unit tests for read-back-through-versions, finalize, invalidate.HostFnBackendtrait extracted fromwasm-exec.SerialExecutoradapted to implement it.SerialExecutor::execute_wavewired throughStateMutator::commit_wave— behavior unchanged from today.
Gate to next phase: differential test passes (serial via new path == serial via old path, byte-for-byte).
Phase C — Parallel scheduler (weeks 4-5)
- Add rayon dependency.
Scheduler+Tasktypes.- Execute → validate → retry loop.
BlockStmExecutor::execute_waveswapped in behind a feature flag.
Gate: differential test passes (parallel == serial across 10⁵ random waves).
Phase D — Access-list prefetch + simulate RPC (week 6)
pyde_simulateTransactionRPC handler.- Pre-execute prefetch step: scheduler unions declared
(addr, slot)pairs, issues one batchedstate_cf.multi_get(PIP-3) into the dashmap (PIP-4) before Block-STM workers start. - Wallet-side helper in
pyde stake(and reused by Otigen's send-tx path).
Gate: prefetched waves measurably faster than no-list waves on a read-heavy benchmark (target: ~30% throughput gain on a wave whose txs all declared accurate lists vs the same wave with empty lists).
Phase E — Determinism testing (weeks 7-8)
- Property tests with proptest: random tx mixes, random pool sizes, identical final state.
- AFL+ fuzz harness against
execute_wave. - Soak test: 24h continuous waves on a 4-validator cluster with random tx mixes; zero state-root divergence required.
Gate: 24h soak test clean.
Phase F — Production swap (week 8+)
Remove the feature flag. BlockStmExecutor becomes the default in pyde validator. SerialExecutor stays compiled cfg(test) only, used by the differential test infrastructure.
Open Questions
- Worker pool sizing. Default to
num_cpus()? Halve it on a host that's also running a libp2p stack? Probably default tonum_cpus / 2with an explicit--executor-threads Noverride. - Failed-tx retention. Block-STM aborts re-incarnate the tx but a hard revert (
HandlerError::*) terminates it. Does the receipt record the abort attempts? No — only the final terminal attempt. Aborts are internal. - Memory pressure. For a 50K-tx wave with high-conflict txs, MVCC could hold tens of thousands of
(tx_index, attempt)versions per slot. Need an eviction policy or hard cap. Probably: cap attempts per tx at 8; on the 9th, fall back to serial-execute-after-all-prior-committed for that tx. Pathological but bounded. - Determinism under wasmtime fuel exhaustion. If a tx runs out of fuel mid-execute, the partial writes are dropped (already the case in serial). Block-STM treats it the same as an explicit revert: receipt with
gas_used = gas_limit, no state changes, no re-incarnation. - Performance target. v1 mainnet aspirational throughput is 10–30K plaintext TPS on commodity hardware (matches Aptos's measured production numbers on pure Block-STM). Block-STM should hit this with ~80% efficiency vs perfect linear scaling. Anything below 70% means the conflict rate is too high in practice; the first lever to pull is improving the access-list prefetch coverage so dashmap hit rate goes up.
Versioning + Upgrade
Block-STM ships in v1 mainnet. The commit_wave interface is stable; v2 changes will be inside BlockStmExecutor and won't affect the chain hash.
Validators can run a mix of v1 and v1.x point-releases without forking — MvccLayer::finalize() outputs are deterministic regardless of pool size or prefetch heuristics. The differential test infrastructure stays in cfg(test) permanently as a regression guard.
If real-world measurements eventually surface a class of contracts whose access patterns are fully static AND whose Block-STM re-execution overhead measurably exceeds the cost of a sequential-within-group path, the optimisation lands in v2 as a per-tx fast path layered on top of the same MVCC core — not a wire-format change. The Block-STM correctness contract holds either way; the fast path would just skip MVCC validation for txs whose declared list fully covers their actual access set and let the rare slips fall back to the standard Block-STM path.
Path Beyond v1
Block-STM at v1 gets Pyde to 10-30K real-world TPS (Aptos's measured production floor under the same model). Pyde's long-term aspirational throughput is meaningfully higher than that, and Block-STM alone does not get there — its effective throughput scales as peak / (1 + 2A) where A is the average re-execution attempts per tx. At low contention (A ≈ 1.05), efficiency is ~85% of peak; at high contention (A ≈ 5), it drops to ~10%. Realistic chain workloads (DEXes, hot-slot NFT mints, popular tokens) push contention toward the latter end during spike events. Pure Block-STM real-world ceiling: somewhere around 50-100K, depending on workload.
The path past that ceiling is additive layers on top of the same Block-STM core. Each layer is justified by a measured throughput gap, not predicted ahead of time. None require a chain fork, a wire-format change, or rewriting the v1 determinism contract.
| Layer | Mechanism | Multiplier | When it lands |
|---|---|---|---|
| L1 — Access-list scheduling fast path | Txs with declared lists that fully cover their actual access set skip MVCC validation and execute sequentially within their declared partition. Rare misses fall back to standard Block-STM. | 1.5-3× on declared-list-heavy workloads | v2 — when conflict rates measurably tank Block-STM throughput |
| L2 — Pipelined execution + consensus | Speculatively execute wave N+1 against state from wave N before N's state-root sigs collect. Commit if N finalizes cleanly; rollback if not. | ~2× | v2-v3 — needs rollback machinery first |
| L3 — Read-write set classification | Distinguish read-only from read-write slot accesses inside the AccessTracker. Read-only accesses never conflict; only RW accesses need MVCC validation. Cuts effective conflict surface 5-10× on read-heavy workloads. | 2-5× at scale | v2 — single AccessTracker change |
| L4 — GPU acceleration for PQ crypto | Move FALCON verify + Kyber threshold decrypt off CPU. PQ crypto is the per-tx tax that dominates execution at scale. | 5-10× on encrypted txs | v2 — driver work |
| L5 — Native pre-compiles for hot patterns | Implement batch transfer, native swap, NFT mint, etc. as host fns in Rust (not WASM). | 10× on specific patterns | v1.x-v2 — pick 3-5 highest-volume patterns at v1 lock |
| L6 — Execution sharding within one chain | State partitioned across N execution shards; consensus unified. Each shard runs its slice of the canonical wave through its own Block-STM scheduler. Cross-shard slot accesses via lightweight 2-phase commit. | Linear in shard count | v3+ — major undertaking |
| L7 — Chain sharding | Multiple sub-chains, cross-shard atomicity via finality cert. | Linear | Post-mainnet — whole-chain rewrite scope |
What is structurally out of scope:
- Object-centric model (Sui): requires every state unit to have explicit ownership encoded in the tx. Pyde's slot-keyed
sstore(slot, value)model is incompatible without breaking the host-fn ABI + the entire WASM execution contract. Off the table. - Replacing Block-STM core with something else for v1: there is no fully-proven alternative for slot-based chains. Aptos, Monad, Polygon Sentinel all converged on Block-STM variants. The industry has voted.
Layering discipline:
Layers ship in order of measured payoff, not theoretical maximum. The first one that lands will be L1 (access-list scheduling fast path) only if measurements show a real conflict-rate problem; L5 (native pre-compiles for hot patterns) might land first if Otigen ecosystem data shows specific patterns dominate volume; L4 (GPU PQ crypto) lands when encrypted-tx volume justifies driver work. Each layer is gated on (a) measurements proving the next ceiling, and (b) a working differential-test surface against the prior layer — so layering can never silently break determinism.
Long-term throughput aspiration of 500K+ TPS is the L1+L2+L3+L5+L6 territory. None of those exist at v1; all of them stack on top of v1's Block-STM core without modifying it. v1 ships with the foundation that makes the path actually reachable, not with the throughput number itself.
Pyde Host Function ABI Specification
Version: v1.0 (draft) Status: Authoritative for v1 mainnet. Subject to revision until mainnet genesis; frozen at v1 launch and only extended in backwards-compatible ways thereafter.
This document is the canonical specification of the Host Function ABI — the surface a WebAssembly contract or parachain uses to interact with the Pyde chain. The execution layer (wasm-exec) is the implementation of this spec. The otigen toolchain validates contracts against this spec at build and deploy time. Independent auditors verify the implementation matches the spec.
If the wasm-exec implementation and this document disagree, this document is authoritative. Implementation bugs are bugs in wasm-exec, not in the spec.
For the conceptual surface and rationale, see Chapter 3 — Execution Layer. For parachain-only extensions, see Chapter 13 — Parachains and companion/PARACHAIN_DESIGN.md.
1. Scope
This spec defines:
- The WASM import module name under which host functions are registered
- The signature of every host function (parameters, returns)
- The semantics of every host function (what it does, what it returns, when it traps)
- The gas cost of every host function (fuel charged per call)
- The error codes returned by every host function
- The memory layout conventions for passing data across the WASM ⇄ host boundary
- The forbidden imports list — functions a deployed module is rejected for importing
- The ABI versioning rules that govern how this spec evolves post-v1
This spec does not define:
- The WASM core instruction set (that is the WebAssembly Core Specification)
- The wasmtime runtime configuration (see Chapter 3 §3.2)
- The toolchain mechanics for declaring host imports in source language (see Chapter 5 — Otigen Toolchain)
- The fuel-to-gas mapping internals (see Chapter 10 §10.1)
2. ABI versioning
2.1 Version field
Every deployed contract declares an ABI version at deploy time. The version is recorded on-chain in the contract's account record. The engine refuses to execute a contract whose declared ABI is newer than the engine's supported ABI.
pyde_abi_version: u32 // semver-packed: high 16 = major, low 16 = minor
Example: 0x0001_0000 = ABI v1.0.
2.2 Compatibility rules
-
Major version bump (v1 → v2) — breaking change. Not permitted post-mainnet. If a future protocol upgrade fundamentally re-shapes the ABI, it ships as v2 alongside v1; the engine supports both forever; old contracts continue to execute under v1 semantics. Major bumps cost the network a hard fork.
-
Minor version bump (v1.0 → v1.1) — backwards-compatible addition. New host functions may be added. Existing function signatures, semantics, gas costs, and error codes are frozen. Old contracts continue to execute without re-deployment.
-
No deprecation, no removal. Once a function is in the ABI, it exists forever at the same signature with the same semantics. This is a one-way ratchet, identical in spirit to Ethereum's opcode discipline.
-
Engine support is monotonic. An engine running ABI v1.7 supports every contract deployed against v1.0 through v1.7. It refuses contracts declaring v1.8 or higher.
Worked example. The
pyde::debug_logtest-only host fn (§9.3) is a canonical backwards-compatible minor bump: one new function added (in the test runner's allowlist; rejected on chain), no existing signature touched, no gas / error-code redefinition. Old contracts that don't import it are unaffected; new contracts get the printf-debug capability during development.
2.3 What does not count as a breaking change
- Bug fixes in the engine's implementation that bring observed behavior into compliance with this spec
- Performance improvements that do not change observable semantics
- Changes to internal data layouts that don't affect WASM-visible byte order
- Adding new gas-cost-zero diagnostics (debug logs, traces) under a
#[cfg(debug)]gate
3. WASM import module + calling conventions
3.0 Entry-point WASM signature
Every function a contract exports to the chain has the WASM-level signature:
(func (export "function_name"))
That is: zero params, zero results. The chain looks up the function by name in the deployed pyde.abi section (§3.7), invokes the exported () -> () WASM function, and exchanges all data through host-function calls — calldata flows in via calldata_size + calldata_copy (§7.4), the return value flows out via pyde::return (§7.7).
Why void-void at the boundary:
-
WASM's value-type vocabulary is too narrow for chain semantics. WASM functions can only pass
i32/i64/f32/f64directly. Pyde transports addresses (32 bytes),u128amounts (16 bytes), variable-length bytes/strings, structs, andVec— none fit a single WASM value-type slot. A multi-arg signature would force the chain to invent a per-function ABI marshalling layer at the WASM boundary, doubling the surface for ABI divergence. Going through linear memory + host fns keeps a single canonical transport (thecalldata_*family) for every entry on every contract. -
Decouples chain ABI from language ABI. Languages disagree on how to lay out structs at function boundaries (Rust's
extern "C", Go's calling convention, AssemblyScript's, etc.). At the void-void boundary the chain doesn't see any of that — each language emits the same() -> ()shape and decodes calldata internally with its own decoder. -
Per-call dispatch stays a single linker entry point. Wasmtime's
Instance::get_typed_func::<(), ()>looks up every entry the same way, with no per-function type construction. This is what makes the dispatch flow in §3.5.2 a single code path regardless of the contract's surface area. -
Cross-contract calls (§13) share the same shape. A
cross_callfrom contract A to contract B re-enters the same() -> ()dispatch path that a top-level tx uses. There is no separate "internal call" ABI.
What this means for SDK authors:
A language SDK's entry-equivalent macro / code generator must produce a WASM export with signature () -> (). The macro is responsible for:
- decoding calldata (via
calldata_size+calldata_copy) into the author's declared argument types, - invoking the author's function body,
- encoding the return value (if any) and surfacing it via
pyde::return.
The reference implementation in Rust is the #[pyde::entry] proc macro in the pyde-host crate. See the SDK Author Guide for the complete contract every SDK must hold up to.
Forbidden export signatures. The deploy validator rejects any exported function whose WASM type isn't () -> (). Two consequences worth flagging:
- A contract cannot expose a "fast path" entry with primitive params (e.g.,
(export "set_value") (func (param i64))). That export would deploy nothing; it must go throughcalldata_copylike every other entry. - A contract cannot return data directly via the WASM result type.
pyde::returnis the only return-data path the engine reads.
fallback is the one exception to the name-based dispatch rule (see §3.5 attribute table) — it's triggered by a name miss, not a name match — but it still has the () -> () WASM signature and reads the unparsed calldata blob via calldata_copy. receive likewise: void-void, with the attached value visible via tx_value (§7.4).
3.1 Import module name
All host functions are registered under the WASM module name pyde. A contract imports functions like:
(import "pyde" "sload" (func (param i32 i32 i32) (result i32)))
(import "pyde" "sstore" (func (param i32 i32 i32)))
(import "pyde" "emit_event" (func (param i32 i32 i32 i32) (result i32)))
Parachain-only host functions are also registered under pyde; they are gated at deploy time by the validator rejecting them for non-parachain contracts (§9.2).
3.2 Pointer + length convention
Pyde host functions pass data across the WASM ⇄ host boundary using i32 byte-pointers into WASM linear memory plus i32 lengths for variable-length data. The conventions are:
| Pattern | Use |
|---|---|
ptr: i32, len: i32 | Caller-allocated input buffer of known length |
ptr: i32 (no length) | Caller-allocated input buffer of fixed length (e.g., 32-byte hash, 32-byte address, 16-byte u128) |
out_ptr: i32 (no length) | Caller-allocated output buffer of fixed length; host writes exactly that many bytes |
out_ptr: i32, out_len_ptr: i32 | Caller-allocated output buffer + a separate i32 pointer where the host writes the actual length used |
All multi-byte integers are little-endian (matching WASM linear memory's native byte order).
Fixed sizes used by the ABI:
| Type | Size (bytes) |
|---|---|
| Address | 32 |
| Slot hash | 32 |
| Hash output (Blake3, Poseidon2, Keccak256) | 32 |
| u128 (balance, value, amount) | 16 |
| u64 (block height, wave id, chain id, timestamp) | 8 |
| u32 (gas, length, counter) | 4 |
3.3 Return values
Every host function returns an i32 result code:
0— success- Positive non-zero — currently unused; reserved for future warning/info codes
- Negative — error (see §4)
Functions that conceptually return data (e.g., balance()) write the data to a caller-provided output pointer and return the i32 result code. Functions that conceptually return a small scalar (e.g., wave_id()) return the scalar directly via WASM's normal return mechanism (e.g., -> i64).
Convention summary:
| Return shape | Function category |
|---|---|
-> i32 (error code only) | Mutating ops without return data (transfer, emit_event). Returns 0 for success; reserved slot lets v2 carry information (event ordinal, byte count, etc.) without a hard fork. |
-> () (no return) | Mutating ops that trap on failure (sstore, sdelete) |
-> i32 + writes to out_ptr | Returns fixed-size data (caller, balance) — writes a known byte width into out_ptr |
-> i32 (actual_len) + writes to out_ptr (up to out_max_len) | Variable-size storage reads (sload) — caller passes a max length, host writes min(actual, max) and returns the true length. -1 for missing. |
-> i32 + writes to out_ptr + out_len_ptr | Returns variable-size data with separate length out-param (calldata_copy, parachain_storage_read) |
-> i64 | Returns a single u64/i64 scalar (wave_id, wave_timestamp) |
(never returns) | Halt operations (return, revert) trap to end execution |
3.4 Memory safety
A host function that receives a pointer + length must validate that the range [ptr, ptr + len) lies entirely within the WASM module's linear memory. Out-of-bounds access traps with MemoryOutOfBounds. This is enforced by the engine; contracts cannot escape the sandbox by passing a malicious pointer.
Maximum linear memory size: 64 MB (hard cap, see Chapter 3 §3.5b). Any read or write past 64 MB traps regardless of pointer value.
3.5 Function attributes
WebAssembly itself has no concept of view/payable/reentrant/etc. — those are chain-level constraints applied at the engine ⇄ WASM boundary. The otigen toolchain reads attributes from otigen.toml and embeds them as a WASM custom section (§3.7) for the engine to consume at runtime.
The attribute set:
| Attribute | Meaning | Enforced by |
|---|---|---|
view | Function must not modify state, transfer value, or emit events | Engine sets view_mode flag on HostState; sstore/sdelete/transfer/emit_event return ERR_FORBIDDEN while flag is set |
payable | Function accepts attached PYDE value (tx.value > 0). Non-payable functions reject value transfers | Engine checks attribute before call; returns ERR_VALUE_TRANSFER_NOT_PAYABLE if value > 0 and attribute absent |
reentrant | Function opts in to being called while already on the call stack. Default is non-reentrant | Engine tracks (contract_addr, fn_name) active set; rejects re-entry of non-reentrant fn with ERR_REENTRANCY_BLOCKED |
sponsored | Gas costs charged to the contract's gas tank instead of the caller | Engine routes gas accounting to contract's tank balance before invocation |
constructor | Callable only at contract deploy time. Subsequent calls are rejected | Deploy validator allows; engine rejects post-deploy with ERR_CONSTRUCTOR_REENTRANT (re-using the reentrancy code is incorrect; treat constructor lockout as a distinct conceptual error category in implementation) |
fallback | Invoked when a call's function name matches no declared function. At most one per contract. Like every other entry, its WASM signature is () -> () (§3.0); it reads the full unparsed calldata via calldata_copy. Default if absent: unmatched name returns ERR_INVALID_FUNCTION_NAME | Engine dispatches to fallback after name-table miss |
receive | Invoked on bare PYDE transfers (no selector, value > 0). At most one per contract. Function takes no arguments. Must also be payable (otherwise it would reject the value it's meant to accept). Default if absent: bare value transfers return ERR_VALUE_TRANSFER_NOT_PAYABLE | Engine dispatches to receive on bare-value tx |
entry | Declares the function is callable from outside the contract (top-level tx or cross_call). Required for any function not marked with another dispatch attribute (constructor, fallback, receive). Internal helpers omit this and are not exposed | Deploy validator strips non-entry non-dispatch fns from the public selector table |
Storage: the attribute bitfield is part of the pyde.abi custom section (§3.7), not the WASM bytecode. The same .wasm would behave identically regardless of attributes — the engine wraps every call with attribute-driven pre-checks.
3.5.1 Attribute compatibility rules
Some combinations are nonsensical or unsafe. The build (otigen build) and the deploy validator BOTH check these. Defense in depth: an author might hand-edit the pyde.abi section to bypass the build check, but the deploy validator catches it.
| Combination | Status | Reason |
|---|---|---|
view + payable | ❌ Rejected | View = no state changes; payable = receives value (state change) |
view + constructor | ❌ Rejected | Constructors initialise state; view can't |
view + reentrant | ❌ Rejected | Views are inherently reentrant (they make no state changes there's no guard to opt out of); the attribute is meaningless on a view |
view + sponsored | ❌ Rejected | Views are FREE (§7.8); sponsoring zero gas is meaningless |
view + fallback | ❌ Rejected | Fallback is the catch-all dispatch; restricting it to read-only is a footgun — authors expect to be able to do anything in a fallback |
view + receive | ❌ Rejected | Receive accepts value; view can't accept value |
payable + constructor | ✅ Allowed | Constructors can initialise with funds |
payable + reentrant | ⚠️ Warning, allowed | DAO-attack pattern. Build emits warning; deploy accepts |
payable + fallback | ✅ Allowed | Generic handler that also accepts value |
constructor + reentrant | ❌ Rejected | Constructors are deploy-only; can't be re-entered |
constructor + sponsored | ❌ Rejected | No gas tank exists at deploy time |
constructor + fallback | ❌ Rejected | Distinct call shapes; constructor is deploy-time, fallback is run-time |
constructor + receive | ❌ Rejected | Same; distinct dispatch contexts |
sponsored + reentrant | ⚠️ Warning, allowed | DAO-attack pattern (contract pays gas for its own re-entry) |
fallback + receive | ❌ Rejected | Distinct triggers (selector-miss vs bare-value); can't be the same handler |
receive + payable | ✅ Required | Receive without payable is a no-op contradiction |
receive + reentrant | ❌ Rejected | Recursive receive is meaningless and dangerous |
3.5.2 Per-call dispatch flow
When the engine invokes a function (top-level tx or cross_call):
1. Look up fn_name in cached ContractAbi
if not found:
if FALLBACK fn exists: dispatch to fallback
else if bare value transfer && RECEIVE fn exists: dispatch to receive
else: return ERR_INVALID_FUNCTION_NAME
2. Read attribute bitfield + access list
3. Apply pre-checks (constructor lockout, payable, reentrancy, sponsored,
view-mode flag, access list install)
4. Apply value transfer (if value > 0 and payable)
5. Push per-tx overlay (nested for cross_call)
6. Invoke WASM function body via wasmtime
7. On return: merge or discard overlay; pop call stack; charge gas
The host-side reference implementation of this dispatch wrapper is the subject of §12.6 + §13.
3.6 Module cache
After the engine compiles a contract's WASM (via Cranelift AOT, see Chapter 3), the compiled wasmtime::Module is large in memory (typically ~2–10× the input WASM size) but expensive to re-derive. Pyde caches it.
ModuleCache (in-memory, per node):
Key: contract_address ([u8; 32])
Value: CachedModule {
compiled: wasmtime::Module, // post-AOT
parsed_abi: ContractAbi, // extracted from pyde.abi custom section
last_used: WaveId, // updated on every invocation
size_bytes: usize, // estimated memory footprint
}
Eviction policy:
- LRU by `last_used` wave
- Hard size cap: MODULE_CACHE_MAX_BYTES (default 1 GB; node-configurable)
- TTL: drop entries with last_used < (current_wave - MODULE_CACHE_TTL_WAVES)
Default TTL: 8 epochs ≈ ~1 day on commodity hardware
- On cache miss: fetch raw .wasm from state_cf, compile via Cranelift,
extract pyde.abi custom section, install entry, return
Properties:
- Hot contracts stay resident → near-zero invocation overhead after first call
- Cold contracts evict → bounded memory footprint
- First-call latency for a cold contract: ~50–200 ms (Cranelift AOT pass)
- Subsequent calls within cache window: ~few μs (cache lookup) + actual exec
- Mirrors the dashmap state cache's design pattern: max size + LRU + TTL
This is conceptually identical to the PIP-4 write-back state cache at the state layer: in-memory hot-path, bounded size, transparent eviction. Cold contracts pay one disk-read + one AOT-compile on revival; hot contracts skip both.
3.7 The pyde.abi custom section
Pyde does not store ABI metadata as separate on-chain state. Instead, the .wasm carries its ABI inside a WebAssembly custom section (a standard WASM binary feature) named pyde.abi. The chain stores only the .wasm bytes; the section travels with the code.
Layout:
.wasm file contains:
[WASM header]
[Type section, Import, Function, Memory, Global, Export, Code, ...]
[Custom section: name="pyde.abi", contents=BORSH(ContractAbi)]
The ContractAbi struct (Borsh-encoded):
#![allow(unused)] fn main() { struct ContractAbi { pyde_abi_version: u32, // semver-packed, must match engine's supported contract_type: ContractType, // Contract | Parachain functions: Vec<FunctionAbi>, state_schema_hash: [u8; 32], // Blake3 of the canonical state schema constructor_index: Option<u32>, // index into functions of the constructor, if any fallback_index: Option<u32>, // index into functions of the fallback, if any receive_index: Option<u32>, // index into functions of the receive, if any } struct FunctionAbi { name: String, // matches the exported WASM function name selector: [u8; 4], // first 4 bytes of Blake3(name) — for dispatch attributes: u32, // bitfield (see §3.5) access_list: Vec<AccessListEntry>, // declared slot patterns } bitflags! { struct Attributes: u32 { const VIEW = 1 << 0; const PAYABLE = 1 << 1; const REENTRANT = 1 << 2; const SPONSORED = 1 << 3; const CONSTRUCTOR = 1 << 4; const FALLBACK = 1 << 5; const RECEIVE = 1 << 6; const ENTRY = 1 << 7; } } }
Build-time: otigen build reads otigen.toml, builds this struct, Borsh-encodes it, and uses a WASM custom-section writer (e.g., the wasm-encoder crate) to inject the section into the .wasm file produced by the language compiler. The code section is untouched; only the metadata appendix is added.
Deploy-time: the deploy validator extracts and parses the pyde.abi section and runs a three-layer validation pipeline (the build-time check is best-effort author ergonomics; the deploy-time re-check is the chain-facing defense; the runtime is the definitive guarantee):
- Schema check — version compatibility (
pyde_abi_version≤ engine's max supported), well-formed Borsh decoding, every required field present. - Cross-reference check — every
FunctionAbi.namematches a WASM(export "name" (func ...)); every WASM-exported function (other than internal helpers — TBD how to mark) appears infunctions[*]. No drift between declarations and code. - Attribute compatibility check — every function's
attributesbitfield is a legal combination per §3.5.1. At most oneFALLBACK, at most oneRECEIVE,RECEIVEimpliesPAYABLE, etc. - Static call-graph check (view enforcement) — for each function with the
VIEWattribute, build the call graph from its body. Walk every transitively-reachable function. If any reachable function importspyde::sstore,pyde::sdelete,pyde::transfer,pyde::emit_event,pyde::parachain_storage_write,pyde::parachain_storage_delete, orpyde::parachain_emit_event, REJECT the deploy withDeployRejected: ViewMutatesState(<fn_name>, <mutating_import>). Indirect calls (call_indirect) are conservatively treated as potentially-anything; a view that usescall_indirectis rejected unless every possible target is also statically provable to be view-safe. - Static access-list check (best-effort) — for each function with a declared access list, scan all statically-resolvable
pyde::sload/sstorecall sites; verify the slot pattern matches the declared list. Dynamic slot computation can't be checked statically — runtime enforcement (Layer 3, below) is the actual guarantee.
On any check failure: deploy is rejected with a specific error code identifying the failing step. On success: the entire .wasm (with custom section intact) is stored in state_cf at the contract's code slot.
Runtime (Layer 3 — the definitive guarantee):
The static checks above are best-effort and cannot catch everything (indirect calls, computed slot hashes, transitive-through-table calls). The runtime is the actual enforcement boundary:
- The engine sets
host_state.view_mode = truebefore invoking aVIEWfunction.host_sstore,host_sdelete,host_transfer,host_emit_event, and the parachain mutating variants all check the flag and returnERR_FORBIDDENif set. A view function that tries to mutate state at runtime traps; the calling tx reverts; the chain is protected. - The engine installs the declared
access_listinhost_state.access_listbefore invoking.host_sload/host_sstorecheck membership; reject withERR_ACCESS_LIST_VIOLATIONon miss. - The engine maintains the active call stack and rejects re-entry into non-
reentrantfunctions.
The chain is therefore safe even if a malicious author hand-crafts a .wasm that bypasses the deploy validator's static checks (e.g., via cleverly-constructed call_indirect patterns) — the runtime catches mutations at the point of attempt. The cost of a bypass attempt is paid by the attacker (gas burned up to the trap, tx reverts, no harm done).
Runtime: the engine loads the .wasm into wasmtime, extracts and parses the pyde.abi section once, caches the parsed ContractAbi alongside the compiled module in the ModuleCache (§3.6). All subsequent invocations of the contract read attributes from this in-memory cache. There is no per-call disk read for ABI metadata.
Wallets and indexers: fetch the .wasm via the RPC pyde_getContractCode(addr) method, parse the pyde.abi custom section client-side (SDKs ship a small helper), and have the full ABI without an extra round trip.
One artifact, one source of truth.
4. Error codes
Negative i32 values returned by host functions. Each function lists which codes it can return; this is the master table.
| Code | Symbol | Meaning |
|---|---|---|
-1 | ERR_INVALID_INPUT | Malformed input bytes (e.g., non-32-byte hash, non-canonical encoding) |
-2 | ERR_NOT_FOUND | Reserved. Storage reads return zero values on missing slots (see sload, balance, parachain_storage_read). Currently only used as a sub-call failure indicator in some cross_call paths. Do not introduce new uses without ABI council review. |
-3 | ERR_INSUFFICIENT_BALANCE | Caller balance too low for the requested operation |
-4 | ERR_OUT_OF_GAS | Gas budget exhausted (typically a trap, but returned here for consume_gas) |
-5 | ERR_FORBIDDEN | Operation not permitted in this context (e.g., sstore from a view function) |
-6 | ERR_ACCESS_LIST_VIOLATION | Accessed slot not in declared access list |
-7 | ERR_OUTPUT_BUFFER_TOO_SMALL | Caller's output buffer was smaller than required |
-8 | ERR_INVALID_ADDRESS | Address format invalid (e.g., 32-byte all-zero, reserved sentinel) |
-9 | ERR_REENTRANCY_BLOCKED | Cross-call would re-enter a non-reentrant function |
-10 | ERR_CROSS_CALL_FAILED | Sub-call trapped or returned non-zero error code |
-11 | ERR_CROSS_CALL_OUT_OF_GAS | Sub-call exhausted forwarded gas |
-12 | ERR_VALUE_TRANSFER_NOT_PAYABLE | Attempted transfer to a function not marked payable |
-13 | ERR_INVALID_FUNCTION_NAME | cross_call target function does not exist |
-14 | ERR_XCALL_RATE_LIMITED | Parachain cross-message budget exceeded for this wave (parachain only) |
-15 | ERR_PARACHAIN_ONLY | Function callable only from parachain context |
-16 | ERR_CIPHERTEXT_INVALID | Threshold-decryption input malformed |
-17 | ERR_SIGNATURE_INVALID | FALCON signature verification failed |
-100 | ERR_INTERNAL | Engine-side bug or unexpected state. Should never occur in a correct implementation; surfaces as a trap in practice. Document for completeness. |
Critical failures (MemoryOutOfBounds, StackOverflow, OutOfFuel, IntegerDivideByZero, UnreachableCodeReached, host-fn-invariant violations) trap. Traps are unrecoverable; the transaction reverts; gas is consumed up to the trap point.
5. Gas metering
Every host function call consumes a fixed base gas cost plus, for variable-length inputs, a per-byte cost. Gas is charged before the host function's work begins. If charging would exceed the contract's remaining gas, the host function traps with OutOfFuel and does not execute.
Gas costs are listed inline with each function and are summarized in the Gas Table at §10. Values in this spec are canonical; the engine's crates/wasm-exec/src/gas_table.rs is the implementation of this table.
The fuel-to-gas mapping is documented in Chapter 10 §10.1. For purposes of this spec, gas = fuel (1:1 at the wasmtime boundary).
5.1 No refunds
Per Chapter 10 §10.1, Pyde v1 has zero gas refunds. sdelete is cheaper than sstore but does not refund. No host function returns gas to the caller.
5.2 Dynamic gas via consume_gas
Contracts that perform off-fuel work (e.g., synchronous loops bounded by external data) can charge gas explicitly via consume_gas(amount). This is metered identically to host-function gas.
6. Determinism rules
A correct Pyde host function call must produce bit-identical results on every honest validator. The following are forbidden in host function implementations:
- Wall-clock time (
std::time::Instant::now(),SystemTime::now()) - Floating-point operations outside the WASM canonical NaN regime
- Non-deterministic RNG (use
beacon_getfor chain-derived randomness) - File system access
- Network calls
- Threading or any concurrency primitive observable to the contract
- Memory allocation patterns that depend on system state (engine uses a fixed-size arena per call)
Host functions that appear to depend on time (wave_timestamp) actually return chain-state-derived values that are deterministic across validators. Same for beacon_get.
The wasmtime configuration (see Chapter 3 §3.2) enforces WASM-side determinism (canonical NaN, no threads, no SIMD, no relaxed-SIMD, no bulk-memory non-determinism, no GC). Host-side determinism is the spec's contract; implementations that violate it are bugs.
7. Core host functions
All functions below are available to every deployed module (contracts + parachains). The pyde:: prefix in WAT examples corresponds to the (import "pyde" "<name>" ...) form.
7.1 Storage
Pyde's storage model is a key-value store with variable-length values (up to MAX_STORAGE_VALUE_BYTES = 16 KB per slot), NOT EVM-style fixed 32-byte words. Keys are 32 bytes (Poseidon2-derived); values are arbitrary raw bytes — Borsh-encoded structs, packed arrays, anything the contract author chooses to write. The width is the contract's call, the chain only enforces the 16 KB upper bound.
Why variable-length, not 32-byte words. WASM operates on linear memory, not 256-bit words; forcing slot values into 32 bytes would (a) require contracts to manually pack non-uint256 data, and (b) burn one slot per logical field regardless of size — blowing up state-tree node count for the common case of small structs. Variable-length lets a Position { trader, size, entry, leverage } at ~80 bytes fit in one slot, one read, one decode. For values larger than 16 KB the canonical pattern is slot-chunking: slot[H(base ‖ i)] = chunk_i.
The 16 KB cap is a RocksDB write-amplification budget (per-slot write costs scale with size; >16 KB starts to hurt LSM compaction). It's a chain-spec parameter, tunable via a future PIP if load demands.
sload
pyde::sload(slot_ptr: i32, out_ptr: i32, out_max_len: i32) -> i32
slot_ptr — pointer to a 32-byte slot key (Poseidon2-derived)
out_ptr — pointer to a contract-allocated buffer to receive the value
out_max_len — size of that buffer (caller-supplied upper bound)
Returns:
>= 0 — actual length of the stored value (may be 0 for an empty value).
The host writes min(actual, out_max_len) bytes into out_ptr.
-1 — SLOAD_MISSING: this slot has never been written, or has been sdeleted.
Gas: GAS_SLOAD = 100 base + 1 per byte copied to out_ptr.
(Cache-warm reads cost the same gas; gas is paid against the worst-case
disk-fetch cost.)
Semantics: a never-written slot returns SLOAD_MISSING (-1), distinct from a slot
that was written with a zero-length value (returns 0). This is a deliberate
departure from EVM's "empty == zero" conflation. The only failure modes are
gas exhaustion (traps) and a malformed out_max_len (negative → traps).
If actual > out_max_len, the contract sees a truncated value AND the true
length as the return value, so the caller knows to retry with a bigger buffer.
sstore
pyde::sstore(slot_ptr: i32, val_ptr: i32, val_len: i32) -> ()
slot_ptr — pointer to a 32-byte slot key
val_ptr — pointer to the raw value bytes to write
val_len — length of the value in bytes (0..=MAX_STORAGE_VALUE_BYTES)
Traps (no return code) on:
- val_len > MAX_STORAGE_VALUE_BYTES (= 16 KB)
- negative val_len
- ERR_FORBIDDEN when called from view mode (cross_call_static sub-call)
- gas exhaustion
Gas: GAS_SSTORE_BASE = 5_000 + GAS_SSTORE_PER_BYTE = 32 per byte of value.
(Same cost for new and overwrite; no cold/warm distinction in v1.
Per-byte component is what makes large writes proportionally expensive.)
sdelete
pyde::sdelete(slot_ptr: i32) -> ()
slot_ptr — pointer to a 32-byte slot key
Traps on:
- ERR_FORBIDDEN when called from view mode
- gas exhaustion
Gas: GAS_SDELETE = 5_000 base.
(Same cost as sstore base — clearing a slot writes a tombstone, which is
a state-tree update equivalent to a write. No refund per PIP-4 gas-no-
refund-v1; the user pays gas_used regardless of the storage delta.)
Semantics: subsequent sload at this slot returns SLOAD_MISSING (-1). Sdelete
on a slot that was never written is a no-op but still charges full gas.
Deriving storage slots
Pyde's canonical slot derivation is:
slot = Poseidon2(self_address || field_bytes [|| key_bytes])
field_bytes is whatever raw bytes the contract chooses (e.g., b"balances"). key_bytes is optional — used for mappings like balances[user_address].
Contracts compute this themselves via hash_poseidon2 + self_address, then call the raw sload / sstore / sdelete above. A typical 5-line helper:
#![allow(unused)] fn main() { fn derive_slot(field: &[u8], key: &[u8]) -> [u8; 32] { let mut preimage = [0u8; 32 + 96]; let total = 32 + field.len() + key.len(); unsafe { host_fns::self_address(preimage.as_mut_ptr()); } preimage[32..32 + field.len()].copy_from_slice(field); preimage[32 + field.len()..total].copy_from_slice(key); let mut out = [0u8; 32]; unsafe { host_fns::hash_poseidon2(preimage.as_ptr(), total as i32, out.as_mut_ptr()); } out } }
This was previously offered as a host-side convenience trio (sload_by_field / sstore_by_field / sdelete_by_field) — dropped in the variable-length storage migration to keep the storage host fn surface minimal and uniform with the engine's executor. The 5-line helper recovers the ergonomics without adding host fns.
The macro-substrate typed-storage host fns (sstore_scalar / sload_scalar / sstore_map<N> / sload_map<N>) derive slots host-side using the same Poseidon2 preimage as the raw path above: Poseidon2(self_address || field_name_bytes [|| key_bytes ...]). Authors using #[pyde::entry] + declare_storage!() get this derivation transparently; raw + typed paths share one canonical slot per (contract, field, keys) triple, so any tooling that resolves a slot from a schema (e.g., otigen test's [tests.expect].storage.<field> resolver) reads the same slot the contract writes regardless of which host-fn surface the contract used.
7.2 Account & balance
balance
pyde::balance(addr_ptr: i32, balance_out_ptr: i32) -> i32
addr_ptr — pointer to 32-byte address
balance_out_ptr — pointer to 16-byte buffer where the u128 balance is written (LE)
Returns: 0 on success, ERR_INVALID_ADDRESS if address malformed.
Gas: 100 base.
Semantics: an address that has never been funded reads back as balance = 0 — NOT
an error. Querying a non-existent account is a normal operation. ERR_INVALID_ADDRESS
fires only for structurally-bad addresses (e.g., reserved sentinel values).
transfer
pyde::transfer(to_ptr: i32, amount_ptr: i32) -> i32
to_ptr — pointer to 32-byte recipient address
amount_ptr — pointer to 16-byte u128 amount (LE)
Returns: 0 on success, ERR_INSUFFICIENT_BALANCE if caller balance < amount,
ERR_INVALID_ADDRESS if recipient malformed,
ERR_FORBIDDEN if called from a view function.
Gas: 7,000 base.
7.3 Execution context
All context functions return chain-state-derived values that are bit-identical across validators.
caller
pyde::caller(addr_out_ptr: i32) -> i32
addr_out_ptr — pointer to 32-byte buffer
Returns: 0 always (caller always exists).
Gas: 5 base.
Semantics: returns the immediate caller's address. For top-level transactions,
caller == origin == the externally-owned account that signed the tx.
For nested cross-calls, caller is the contract that issued the cross_call.
origin
pyde::origin(addr_out_ptr: i32) -> i32
addr_out_ptr — pointer to 32-byte buffer
Returns: 0 always.
Gas: 5 base.
Semantics: returns the externally-owned account that signed the original transaction,
regardless of cross-call nesting depth. Deliberately distinct from caller() to avoid
the tx.origin phishing footgun from Ethereum (origin should rarely be checked for
authorization).
self_address
pyde::self_address(addr_out_ptr: i32) -> i32
addr_out_ptr — pointer to 32-byte buffer
Returns: 0 always.
Gas: 5 base.
Semantics: returns the address of the currently-executing contract or parachain.
wave_id
pyde::wave_id() -> i64
Returns: the current wave id as a u64. Pyde's consensus-round counter,
monotonically increasing.
Gas: 2 base.
wave_timestamp
pyde::wave_timestamp() -> i64
Returns: the canonical timestamp of the wave being committed, in seconds since Unix epoch.
This value is committee-attested and identical across all validators.
Gas: 2 base.
chain_id
pyde::chain_id() -> i64
Returns: the chain identifier (1 = mainnet, 31337 = devnet, others TBD).
Gas: 2 base.
7.4 Transaction context
tx_hash
pyde::tx_hash(hash_out_ptr: i32) -> ()
hash_out_ptr — pointer to 32-byte buffer
Writes the current transaction's Blake3 hash to `hash_out_ptr`.
Gas: 5 base.
tx_value
pyde::tx_value(value_out_ptr: i32) -> ()
value_out_ptr — pointer to 16-byte buffer (u128, LE)
Writes the PYDE value attached to the current call to `value_out_ptr`.
For non-payable functions this is always zero; for payable functions, it is the
amount passed in by the caller (top-level tx.value or cross_call's value argument).
Gas: 5 base.
tx_gas_remaining
pyde::tx_gas_remaining() -> i64
Returns: remaining gas (fuel) in the current call frame.
Gas: 2 base.
calldata_size
pyde::calldata_size() -> i32
Returns: total length in bytes of the calldata buffer for the current invocation.
Gas: 2 base.
calldata_copy
pyde::calldata_copy(offset: i32, len: i32, out_ptr: i32) -> i32
offset — byte offset into the calldata buffer
len — number of bytes to copy
out_ptr — pointer to len-sized buffer
Returns: 0 on success, ERR_INVALID_INPUT if (offset + len) exceeds calldata_size().
Gas: 8 base + 1 per byte copied.
7.5 Events
emit_event
pyde::emit_event(
topics_ptr: i32, — pointer to (topics_count × 32) bytes of topic data
topics_count: i32, — number of topics; must be 1 ≤ topics_count ≤ 4
data_ptr: i32,
data_len: i32,
) -> i32
topics_ptr — pointer to topics_count consecutive 32-byte topic values
topics_count — 1 to 4 inclusive; topic[0] is conventionally Blake3(signature)
data_ptr, len — variable-length non-indexed event payload
Returns: 0 on success,
ERR_FORBIDDEN if called from a view function,
ERR_INVALID_INPUT if topics_count < 1 or topics_count > 4,
ERR_INVALID_INPUT if data_len > MAX_EVENT_DATA_SIZE.
Gas: 100 base + 50 × topics_count + 8 per data byte.
(Each topic adds 32 bytes of state-commitment cost; 50 gas per topic
covers the bloom-set + per-topic index write.)
Semantics:
Appends an event record to the current overlay's events buffer. Topic
semantics follow the §14.1 convention:
- topic[0] = Blake3(canonical_event_signature). Identifies the event type;
this is what subscribers and indexers match on as the primary filter.
- topic[1..topics_count] = indexed field values, in declaration order.
Each indexed field's value occupies one 32-byte topic slot. Authors
declare which fields are indexed in otigen.toml (§14.1).
At wave commit (§15), the events buffer flushes atomically with state:
- One row to events_cf (primary, keyed by (wave_id, tx_index, event_index))
- topics_count rows to events_by_topic_cf (one per topic value)
- One row to events_by_contract_cf (keyed by contract_addr)
- Every topic + the contract_addr is added to the wave's events_bloom
- The event participates in the wave's events_root Merkle tree
Events from a reverted (sub-)call are discarded along with the overlay;
the chain never sees events from a path that did not commit.
7.6 Hashing primitives
All three accept variable-length input and write a 32-byte output.
hash_blake3
pyde::hash_blake3(in_ptr: i32, in_len: i32, out_ptr: i32) -> ()
Reads `in_len` bytes from `in_ptr`, writes the 32-byte Blake3 digest to `out_ptr`.
Gas: 15 base + 3 per word (8 bytes), rounded up.
hash_poseidon2
pyde::hash_poseidon2(in_ptr: i32, in_len: i32, out_ptr: i32) -> ()
Reads `in_len` bytes from `in_ptr`, writes the 32-byte Poseidon2 digest to `out_ptr`.
Gas: 100 base + 30 per word (8 bytes), rounded up.
Notes: ZK-friendly hash; significantly more expensive than Blake3 in native execution.
Use where ZK-circuit-friendly output is required (state-root commitments, address
derivation). Use Blake3 everywhere else.
hash_keccak256
pyde::hash_keccak256(in_ptr: i32, in_len: i32, out_ptr: i32) -> ()
Reads `in_len` bytes from `in_ptr`, writes the 32-byte Keccak-256 digest to `out_ptr`.
Gas: 30 base + 6 per word (8 bytes), rounded up.
Notes: provided for cross-chain interoperability. Pyde's native hashes are Blake3
(performance path) and Poseidon2 (ZK path). Keccak256 is for verifying Ethereum-style
inputs (Merkle Patricia proofs, etc.).
7.7 Post-quantum cryptography
falcon_verify
pyde::falcon_verify(
pk_ptr: i32, — pointer to ~897-byte FALCON-512 public key
msg_ptr: i32, msg_len: i32,
sig_ptr: i32, sig_len: i32
) -> i32
Returns: 0 if signature is valid, ERR_SIGNATURE_INVALID otherwise.
Gas: 50,000 base. (Reflects the ~80μs cost on commodity x86_64 commodity hardware.)
7.8 Cross-contract calls
cross_call
pyde::cross_call(
target_ptr: i32, — pointer to 32-byte target contract address
fn_name_ptr: i32, fn_name_len: i32,— UTF-8 function name to invoke
calldata_ptr: i32, calldata_len: i32,
value_ptr: i32, — pointer to 16-byte u128 value to attach (0 = no transfer)
gas_limit: i64, — gas budget for the sub-call
return_data_out_ptr: i32,
return_data_out_len_ptr: i32 — pointer to i32 written with actual return length
) -> i32
Returns: 0 on success; sub-call's negative error code on failure;
ERR_CROSS_CALL_FAILED if sub-call trapped;
ERR_CROSS_CALL_OUT_OF_GAS if sub-call exhausted forwarded gas;
ERR_REENTRANCY_BLOCKED if target function is non-`reentrant` and caller would
re-enter it;
ERR_INVALID_FUNCTION_NAME if target function does not exist;
ERR_VALUE_TRANSFER_NOT_PAYABLE if value > 0 and target is non-`payable`.
Gas: 1,000 base + 8 per byte of calldata + sub-call's actual gas_used.
Semantics: synchronous call to another contract within the same wave. The sub-call
runs in a nested per-tx overlay (see [Chapter 3 §3.5b](../chapters/03-virtual-machine.md)).
On sub-call success: the overlay merges into the parent on cross_call return.
On sub-call trap or non-zero error: the overlay is discarded; parent state untouched.
Caller's remaining gas is decremented by sub-call's actual gas_used regardless of outcome.
cross_call_static
pyde::cross_call_static(
target_ptr: i32,
fn_name_ptr: i32, fn_name_len: i32,
calldata_ptr: i32, calldata_len: i32,
gas_limit: i64,
return_data_out_ptr: i32,
return_data_out_len_ptr: i32
) -> i32
Returns: as above, but target must be a `view`-attributed function (returns
ERR_FORBIDDEN otherwise).
Gas: 50 base for the dispatch (caller pays). Sub-call execution itself is FREE
to the caller — see "View calls are free" below.
Semantics: view-only variant. Sub-call may not modify state, emit events, or
transfer value. Useful for safe queries across contracts.
View calls are free:
- Off-chain via RPC pyde_call(contract, fn, calldata): completely free; no
tx, no consensus, no gas accounting.
- On-chain via this host fn: ALSO free for the caller. The dispatch base
cost (50 gas) covers setup; the sub-call's actual execution does not
debit the caller's remaining gas.
- View functions cannot mutate state, so the chain doesn't need to charge
for them as an economic incentive — the rationale for charging state-
mutating ops doesn't apply.
Bounding mechanism (DoS prevention):
- Each cross_call_static invocation initialises its wasmtime instance with
a per-call FUEL CAP, default VIEW_FUEL_CAP = 10_000_000 (~3ms commodity).
- Configurable per node operator (NodeConfig.view_fuel_cap).
- If the view exhausts the cap: trap with OutOfFuel; cross_call_static
returns ERR_CROSS_CALL_OUT_OF_GAS to caller; caller's actual gas budget
is NOT debited for the sub-call's work.
- The cap exists purely to bound per-call wall-clock time so a malicious
contract can't burn unbounded validator CPU via view spam.
delegate_call
pyde::delegate_call(
target_ptr: i32, — pointer to 32-byte target contract address
(whose CODE will run)
fn_name_ptr: i32, fn_name_len: i32,
calldata_ptr: i32, calldata_len: i32,
gas_limit: i64,
return_data_out_ptr: i32,
return_data_out_len_ptr: i32
) -> i32
Returns: 0 on success; sub-call's negative error code on failure;
ERR_CROSS_CALL_FAILED if sub-call trapped;
ERR_CROSS_CALL_OUT_OF_GAS if sub-call exhausted forwarded gas;
ERR_INVALID_FUNCTION_NAME if target function does not exist;
ERR_REENTRANCY_BLOCKED if (caller_addr, target_fn) is already on the call stack
and target_fn is not `reentrant`.
Gas: 1,200 base + 8 per byte of calldata + sub-call gas_used.
(Slightly higher base than cross_call because the engine must keep the caller's
overlay active rather than push a fresh one.)
Semantics: execute target contract's CODE in the CALLER'S STORAGE CONTEXT.
Concretely:
- Loads target's WASM + parsed ABI
- Invokes target's named function, but with the engine's HostState configured
so that:
* sload/sstore hit the caller's slots (NOT the target's)
* self_address() returns the caller's address (NOT the target's)
* caller() returns the original caller of the OUTER function
* origin() unchanged (still tx originator)
* tx_value() unchanged (still the value attached to the outer call)
- Access list enforcement is against the CALLER'S declared list (not the
target's) — the target's code may try to access slots the caller hasn't
declared, which fails with ERR_ACCESS_LIST_VIOLATION
- No value transfer happens (delegate_call doesn't move PYDE — the called
code operates on the caller's balance directly)
- Reentrancy guard applies to (caller_addr, target_fn_name)
Use cases:
- Upgradeable contracts: proxy contract holds state; delegate_call to an
implementation contract for logic. Upgrade = swap which implementation
address the proxy delegates to.
- Libraries: shared logic deployed once; per-caller state via delegate_call.
Risks for authors:
- Target's code can corrupt caller's storage if their slot layouts differ.
- Target's code can transfer caller's funds (self_address is the caller).
- This is the same risk model as EVM's delegatecall; the v1 spec does not
add any structural guardrails beyond access-list enforcement. Authors are
expected to use delegate_call only with target contracts they fully trust.
7.9 Halt operations
return
pyde::return(data_ptr: i32, data_len: i32) -> (never returns)
Sets the current call frame's return data and exits successfully. The data is
visible to the caller via cross_call's return_data_out_ptr.
Gas: 0 base (the trap exits the call frame).
revert
pyde::revert(reason_ptr: i32, reason_len: i32) -> (never returns)
Reverts the current call frame. All state changes since the call started are
discarded (the per-tx overlay is dropped). The reason bytes are made available
to the caller as the failure payload.
Gas: 0 base.
7.10 Explicit gas metering
consume_gas
pyde::consume_gas(amount: i64) -> i32
Returns: 0 on success, ERR_OUT_OF_GAS if amount exceeds remaining gas (and the
function traps with OutOfFuel — the i32 return is for documentation only).
Gas: 2 base + amount (so `consume_gas(N)` total cost is N+2).
Use case: contracts that perform off-fuel work (synchronous loops bounded by
external data, expensive computations charged against the user's gas budget) call
consume_gas explicitly to make the charge visible.
7.11 VRF beacon
beacon_get
pyde::beacon_get(out_ptr: i32) -> i32
out_ptr — pointer to 32-byte buffer
Returns: 0 always; writes the current wave's committee-derived VRF beacon
(XOR of all members' beacon shares from the prior anchor round).
Gas: 50 base.
Semantics: deterministic, public randomness, identical across all validators. Use as
a chain-derived random source. Note that the beacon is *publicly predictable* within a
wave — adversaries cannot bias it, but they *can* observe it. Use threshold encryption
if you need adversary-private randomness.
8. Parachain-only host functions
These functions are available only to modules deployed with type = "parachain". The deploy-time validator rejects any non-parachain module that imports any function in this section. Attempting to call a parachain function from a non-parachain context (theoretically impossible after deploy validation, surfaces as an engine bug) returns ERR_PARACHAIN_ONLY.
For the parachain design rationale, see companion/PARACHAIN_DESIGN.md.
8.1 Parachain storage
parachain_storage_read
pyde::parachain_storage_read(
key_ptr: i32, key_len: i32,
value_out_ptr: i32,
value_out_len_ptr: i32
) -> i32
Returns: 0 on success,
ERR_OUTPUT_BUFFER_TOO_SMALL if the value exists but caller's buffer is too small.
Gas: 250 base + 1 per byte returned.
Semantics: read from this parachain's state subtree (PIP-2 clustered under
parachain_id[..16]). Variable-length **keys** (unlike core `sload`, which takes
a fixed 32-byte slot key); variable-length values up to MAX_STORAGE_VALUE_BYTES
(same 16 KB cap as core `sload`). A key that was never written returns success
with *out_len_ptr written as 0 — NOT an error. Callers check the written length
to distinguish "empty value" from "value too large for my buffer."
parachain_storage_write
pyde::parachain_storage_write(
key_ptr: i32, key_len: i32,
value_ptr: i32, value_len: i32
) -> i32
Returns: 0 on success, ERR_FORBIDDEN if called from a view function.
Gas: 5,500 base + 10 per byte stored.
parachain_storage_delete
pyde::parachain_storage_delete(key_ptr: i32, key_len: i32) -> i32
Returns: 0 on success (even if key did not exist), ERR_FORBIDDEN if view fn.
Gas: 250 base.
8.2 Parachain context
parachain_id
pyde::parachain_id(out_ptr: i32) -> i32
out_ptr — pointer to 32-byte buffer
Returns: 0 always; writes this parachain's ID (Poseidon2 of "pyde-parachain:" || name).
Gas: 5 base.
parachain_version
pyde::parachain_version() -> i32
Returns: the current parachain's active version (u32).
Gas: 5 base.
8.3 Parachain events
parachain_emit_event
pyde::parachain_emit_event(
topics_ptr: i32,
topics_count: i32, — 1 to 4 inclusive; topic[0] = Blake3(signature)
data_ptr: i32,
data_len: i32,
) -> i32
Returns: 0 on success,
ERR_FORBIDDEN if view fn,
ERR_INVALID_INPUT if topics_count out of range or data oversized.
Gas: 100 base + 50 × topics_count + 8 per data byte.
Semantics: identical to the core emit_event (§7.5) including multi-topic
support and the indexed-field convention. The event is filed under the
parachain's own event-stream namespace (the contract_addr field of the
EventRecord carries the parachain_id) so subscribers can filter for a
specific parachain's events. Same storage layout and indexing as core
events (§15.3).
8.4 Cross-parachain messaging
send_xparachain_message
pyde::send_xparachain_message(
target_id_ptr: i32, — pointer to 32-byte destination parachain ID
msg_ptr: i32, msg_len: i32, — opaque payload
callback_fn_name_ptr: i32, callback_fn_name_len: i32, — function on this parachain
max_callback_gas: i64,
timeout_waves: i64 — give up after this many waves
) -> i64
Returns: positive XCallId (u64) on success;
negative error code: ERR_XCALL_RATE_LIMITED if budget exceeded,
ERR_INVALID_INPUT if target_id is malformed, ERR_INVALID_FUNCTION_NAME if
callback function does not exist on this parachain.
Gas: 10,000 base + 8 per byte of msg_len.
Semantics: queue an asynchronous message to the target parachain. The calling
parachain's committee threshold-signs the message; the target parachain's committee
verifies and dispatches. Result (or timeout) arrives later as a callback transaction
that invokes the named callback_fn on this parachain. See PARACHAIN_DESIGN §9 for
the full flow.
Rate limit: 64 outgoing messages per wave per parachain by default
(parachain-configurable).
8.5 Threshold cryptography
These are exposed to parachains for application-level confidentiality use cases (blinded auctions, sealed-bid markets, MEV-protected DEX matching at parachain layer).
threshold_encrypt
pyde::threshold_encrypt(
plaintext_ptr: i32, plaintext_len: i32,
ciphertext_out_ptr: i32,
ciphertext_out_len_ptr: i32
) -> i32
Returns: 0 on success, ERR_OUTPUT_BUFFER_TOO_SMALL if buffer insufficient.
Gas: 80,000 base + 100 per byte.
Semantics: encrypt under the current epoch's threshold public key. Result is a
Kyber-768 KEM envelope + ChaCha20-Poly1305 ciphertext. Decryption requires ≥85
shares (combined by the chain at appropriate ceremony points).
threshold_decrypt
pyde::threshold_decrypt(
ciphertext_ptr: i32, ciphertext_len: i32,
plaintext_out_ptr: i32,
plaintext_out_len_ptr: i32
) -> i32
Returns: 0 on success, ERR_CIPHERTEXT_INVALID if malformed,
ERR_FORBIDDEN if the calling parachain has not yet hit a wave where the
committee has combined shares for this ciphertext.
Gas: 100,000 base + 50 per byte.
Semantics: decrypt a ciphertext for which the committee has already executed the
threshold-decryption ceremony. The combined plaintext is materialized into the
output buffer. This is parachain-only because cross-parachain ceremony coordination
requires the parachain-specific committee infrastructure.
9. Forbidden imports
9.1 Hard-rejected at deploy time
The deploy validator rejects any module whose WASM import section references any of the following. Attempting to deploy such a module returns DeployRejected: ForbiddenImport(<name>).
| Module | Function | Reason |
|---|---|---|
wasi_snapshot_preview1 | (any) | File I/O, system clock, env vars — non-deterministic |
wasi_unstable | (any) | Same |
wasi:* | (any) | Same |
env | (any) | Generic env-namespace functions out of scope for Pyde ABI |
pyde | debug_log | Test-only. Provided by the otigen-test runner for console.log-style printf debugging. Production deployments MUST strip these calls before deploy. See §9.3. |
pyde | other functions not in this spec | Future-proofing; rejects modules built against an unreleased ABI version |
| Any other module name | (any) | Single permitted namespace is pyde. |
9.2 Parachain functions called from non-parachain modules
If a non-parachain module imports a function from §8, the deploy validator rejects the deployment with DeployRejected: ParachainOnly(<name>). The eligible-import set is determined by the contract's declared type in otigen.toml.
9.3 Test-only imports (otigen-test runner)
The otigen-test runner provides one extra pyde::* import that is forbidden on the chain but available during local development for console.log-style debugging.
debug_log
pyde::debug_log(msg_ptr: i32, msg_len: i32) -> ()
msg_ptr — pointer to UTF-8 message bytes (lossy decoding tolerates non-UTF-8)
msg_len — message length (max 4 KB; exceeding traps)
Returns: nothing.
Gas: untracked (test-only).
Semantics (test runner): writes "[debug] <fn_name>: <msg>" to stderr. Also
captured in TestEnv.debug_logs for programmatic access in trace renderers.
Semantics (chain): rejected at deploy time. The contract MUST NOT import this
fn in any module shipped to mainnet or testnet.
Use cases: ad-hoc value dumps, breadcrumb traces, asserting intermediate state in tests without polluting events. Bridges the gap that previously forced devs to call revert(b"value=42") to surface intermediate values.
Stripping for deploy: otigen build is strict by default and rejects any bundle that imports pyde::debug_log, surfacing ValidationError::TestOnlyHostFn. Pass --no-strict to opt out for local inspection — otigen deploy always runs the strict gate and ignores --no-strict, so the chain never sees a debug_log import. The chain's deploy validator also hard-rejects modules whose import section names debug_log regardless of how they were bundled (defence in depth).
| Path | Test-only fns accepted? |
|---|---|
otigen build (default) | no — strict is default |
otigen build --no-strict | yes — local-only escape hatch |
otigen check | yes |
otigen deploy | no — strict, not opt-out-able |
otigen test runner | mocked (writes to stderr) |
The honour-system rule is therefore: drop debug_log calls (or guard them behind #[cfg(feature = "debug")]) before shipping. A grep over the source tree (grep -rn debug_log src/) is a fast pre-flight check, but the build gate catches anything that slips.
9.4 WASM features rejected at instantiation time
The wasmtime config (see Chapter 3 §3.2) rejects modules that use:
- Threads (
wasm_threads) - SIMD (
wasm_simd,wasm_relaxed_simd) - Reference types (
wasm_reference_types) - GC (
wasm_gc) - Function references (
wasm_function_references) - Multiple memories (
wasm_multi_memory) - Memory64 (
wasm_memory64) - Component model (
wasm_component_model)
These cannot be opted into per-contract. They are network-wide forbidden.
10. Gas table
Authoritative gas costs for every host function. This table is the source of truth; if the engine implementation diverges, the engine is wrong.
| Function | Base gas | Per-byte / per-word | Notes |
|---|---|---|---|
sload | 100 | 1 / byte copied | Returns actual length or -1 (SLOAD_MISSING) |
sstore | 5,000 | 32 / byte | Variable-length value (≤ 16 KB) |
sdelete | 5,000 | — | No refund (PIP-4 gas-no-refund) |
balance | 100 | — | |
transfer | 7,000 | — | |
caller, origin, self_address | 5 | — | |
wave_id, wave_timestamp, chain_id | 2 | — | |
tx_hash | 5 | — | |
tx_value | 5 | — | |
tx_gas_remaining | 2 | — | |
calldata_size | 2 | — | |
calldata_copy | 8 | 1 / byte | |
emit_event | 100 | + 50 / topic + 8 / data byte | 1 to 4 topics; topic[0] conventionally signature hash |
hash_blake3 | 15 | 3 / word (8 bytes) | |
hash_poseidon2 | 100 | 30 / word | ZK-friendly, expensive |
hash_keccak256 | 30 | 6 / word | EVM-compat |
falcon_verify | 50,000 | — | ~80μs commodity |
cross_call | 1,000 | 8 / byte calldata + sub-call gas | |
cross_call_static | 50 | — | Sub-call execution is FREE; caller pays only the dispatch base. Sub-call bounded by VIEW_FUEL_CAP (default 10M instructions ≈ 3ms) |
delegate_call | 1,200 | 8 / byte calldata + sub-call gas | Caller's storage context |
return | 0 | — | Halt op |
revert | 0 | — | Halt op |
consume_gas | 2 | + amount | Pure manual metering |
beacon_get | 50 | — | |
parachain_storage_read | 250 | 1 / byte returned | Parachain only |
parachain_storage_write | 5,500 | 10 / byte | Parachain only |
parachain_storage_delete | 250 | — | Parachain only |
parachain_id | 5 | — | Parachain only |
parachain_version | 5 | — | Parachain only |
parachain_emit_event | 100 | + 50 / topic + 8 / data byte | Parachain only; same multi-topic surface as core emit_event |
send_xparachain_message | 10,000 | 8 / byte | Parachain only |
threshold_encrypt | 80,000 | 100 / byte | Parachain only |
threshold_decrypt | 100,000 | 50 / byte | Parachain only |
Per-word = per-8-bytes, rounded up. Per-byte = per-1-byte, no rounding.
These values are initial calibration, set against representative benchmarks for commodity validator hardware. The benchmark harness (see companion/PERFORMANCE_HARNESS.md) is the authority for production calibration; pre-mainnet sweeps may revise these numbers up or down by ≤2× without changing the ABI version (gas tables are an implementation detail, not part of the binary signature).
11. Native (non-WASM) transaction types
Several transaction types bypass the WASM execution layer entirely and run as native handlers in the engine. These do not use the Host Function ABI — they are listed here for completeness so contract authors understand which operations are "free of WASM overhead":
| Transaction type | Cost | Path |
|---|---|---|
Standard with value > 0 and empty data | ~21,000 gas | Native fast path inside the Standard handler — no wasmtime instantiation |
StakeDeposit (0x03) | Native | |
StakeWithdraw (0x04) | Native | |
Slash (0x05) | Native | |
ClaimReward (0x06) | Native | |
ClaimAirdrop (0x07) | Native | |
SweepAirdrop (0x08) | Native | |
MultisigTx (0x09) | Native | Dispatches into native multisig handler before optional inner-call routing |
MultisigSignerRotate (0x0A) | Native |
See Chapter 3 §3.9b for the dispatch logic.
12. Invoking host functions from contract code
This section explains the WASM imports mechanism with concrete language examples — the most-asked question from contract authors.
12.1 What an import declaration actually is
A WebAssembly module's binary format includes an import section listing every external function the module needs. Each entry pairs a (module_name, function_name) with a function type signature. The module body never includes the implementation; it just declares "I'll call this — somebody provide it at instantiation time."
Pyde reserves the module name pyde for all host functions. A contract that declares an import like:
(import "pyde" "sload" (func (param i32 i32 i32) (result i32)))
is saying: "Give me a function named sload from module pyde, taking (i32, i32, i32) and returning i32 (the (slot_ptr, out_ptr, out_max_len) -> actual_len shape)." At instantiation time, wasmtime walks the import section and looks each one up in a host-provided Linker. If the entry exists, the contract's call is wired to the host's Rust implementation. If not, instantiation fails — and the deploy validator rejects the contract before it ever reaches a node.
12.2 Rust contract — declaring imports
#![allow(unused)] fn main() { // All host functions go under module "pyde" #[link(wasm_import_module = "pyde")] extern "C" { fn sload(slot_ptr: u32, value_out_ptr: u32) -> i32; fn sstore(slot_ptr: u32, value_ptr: u32) -> i32; fn caller(addr_out_ptr: u32) -> i32; fn emit_event( topic_ptr: u32, topic_len: u32, data_ptr: u32, data_len: u32, ) -> i32; fn hash_blake3(in_ptr: u32, in_len: u32, out_ptr: u32) -> i32; } #[no_mangle] pub extern "C" fn store_and_read() -> i32 { let slot = [0x42u8; 32]; let value_in = [0xAAu8; 32]; let mut value_out = [0u8; 32]; unsafe { sstore(slot.as_ptr() as u32, value_in.as_ptr() as u32); sload(slot.as_ptr() as u32, value_out.as_mut_ptr() as u32) } } }
Compile with cargo build --target wasm32-unknown-unknown --release. Inspect with wasm-objdump -x:
Import[5]:
- func[0] sig=2 <pyde.sload>
- func[1] sig=2 <pyde.sstore>
- func[2] sig=3 <pyde.caller>
- func[3] sig=4 <pyde.emit_event>
- func[4] sig=5 <pyde.hash_blake3>
No Pyde library dependency. No code generation. Just extern declarations and the attribute that targets the pyde import namespace.
12.3 AssemblyScript contract — same imports
// AssemblyScript uses @external decorators
@external("pyde", "sload")
declare function sload(slotPtr: usize, valueOutPtr: usize, valueMaxLen: i32): i32;
@external("pyde", "sstore")
declare function sstore(slotPtr: usize, valuePtr: usize, valueLen: i32): void;
@external("pyde", "caller")
declare function caller(addrOutPtr: usize): i32;
@external("pyde", "emit_event")
declare function emit_event(
topicPtr: usize, topicLen: usize,
dataPtr: usize, dataLen: usize
): i32;
@external("pyde", "hash_blake3")
declare function hash_blake3(inPtr: usize, inLen: usize, outPtr: usize): i32;
export function store_and_read(): i32 {
const slot = new ArrayBuffer(32);
const valueIn = new ArrayBuffer(32);
const valueOut = new ArrayBuffer(32);
// Fill slot with 0x42, valueIn with 0xAA
const slotPtr = changetype<usize>(slot);
const valueInPtr = changetype<usize>(valueIn);
for (let i: i32 = 0; i < 32; i++) {
store<u8>(slotPtr + i, 0x42);
store<u8>(valueInPtr + i, 0xAA);
}
sstore(slotPtr, valueInPtr);
return sload(slotPtr, changetype<usize>(valueOut));
}
Compile with npx asc store_and_read.ts -o store_and_read.wasm --target release. Resulting WASM has the same import structure. The runtime can't tell which language produced it.
12.4 Go (TinyGo) contract — same imports
//go:wasmimport pyde sload
func sload(slotPtr uint32, valueOutPtr uint32) int32
//go:wasmimport pyde sstore
func sstore(slotPtr uint32, valuePtr uint32) int32
//go:wasmimport pyde emit_event
func emit_event(topicPtr, topicLen, dataPtr, dataLen uint32) int32
//go:export store_and_read
func StoreAndRead() int32 {
slot := [32]byte{}
for i := range slot { slot[i] = 0x42 }
valueIn := [32]byte{}
for i := range valueIn { valueIn[i] = 0xAA }
var valueOut [32]byte
slotPtr := uint32(uintptr(unsafe.Pointer(&slot[0])))
valueInPtr := uint32(uintptr(unsafe.Pointer(&valueIn[0])))
valueOutPtr := uint32(uintptr(unsafe.Pointer(&valueOut[0])))
sstore(slotPtr, valueInPtr)
return sload(slotPtr, valueOutPtr)
}
Compile with tinygo build -target=wasm-unknown -o store_and_read.wasm. Same WASM output shape.
12.5 C / C++ contract — same imports
__attribute__((import_module("pyde"), import_name("sload")))
extern int32_t sload(int32_t slot_ptr, int32_t value_out_ptr);
__attribute__((import_module("pyde"), import_name("sstore")))
extern int32_t sstore(int32_t slot_ptr, int32_t value_ptr);
__attribute__((import_module("pyde"), import_name("emit_event")))
extern int32_t emit_event(int32_t topic_ptr, int32_t topic_len,
int32_t data_ptr, int32_t data_len);
__attribute__((export_name("store_and_read")))
int32_t store_and_read(void) {
uint8_t slot[32]; for (int i = 0; i < 32; i++) slot[i] = 0x42;
uint8_t value_in[32]; for (int i = 0; i < 32; i++) value_in[i] = 0xAA;
uint8_t value_out[32];
sstore((int32_t)(uintptr_t)slot, (int32_t)(uintptr_t)value_in);
return sload((int32_t)(uintptr_t)slot, (int32_t)(uintptr_t)value_out);
}
Compile with clang --target=wasm32 -nostdlib -Wl,--no-entry -o store_and_read.wasm store_and_read.c. Same WASM output shape.
12.6 Host side — how the engine handles invocations
In Pyde's wasm-exec Rust crate, every function in this spec is registered with wasmtime's Linker at engine startup. When a contract is instantiated, wasmtime walks the contract's import section and binds each one to its registered handler:
#![allow(unused)] fn main() { // Engine startup — once per node lifetime pub fn build_linker(engine: &wasmtime::Engine) -> Linker<HostState> { let mut linker = Linker::new(engine); // Register every host function from §7 and §8 linker.func_wrap("pyde", "sload", host_sload).unwrap(); linker.func_wrap("pyde", "sstore", host_sstore).unwrap(); linker.func_wrap("pyde", "caller", host_caller).unwrap(); linker.func_wrap("pyde", "emit_event", host_emit_event).unwrap(); linker.func_wrap("pyde", "hash_blake3", host_hash_blake3).unwrap(); // ... 30+ more linker } // sload implementation (variable-length value, returns actual_len or // SLOAD_MISSING = -1 for never-written slots) fn host_sload( mut caller: Caller<'_, HostState>, slot_ptr: i32, out_ptr: i32, out_max_len: i32, ) -> i32 { // 1. Charge base gas FIRST (before any work); per-byte gas charged // after we know the value length. if caller.consume_fuel(SLOAD_BASE_GAS).is_err() { return ERR_OUT_OF_GAS; // documentation; wasmtime traps with OutOfFuel } // 2. Get the contract's exported linear memory let memory = match caller.get_export("memory") { Some(wasmtime::Extern::Memory(m)) => m, _ => return ERR_INTERNAL, }; // 3. Read the slot hash from WASM memory (bounds-checked by wasmtime) let mut slot_bytes = [0u8; 32]; if memory.read(&caller, slot_ptr as usize, &mut slot_bytes).is_err() { return ERR_INVALID_INPUT; } // 4. Access-list check if !caller.data().access_list.contains(&slot_bytes) { return ERR_ACCESS_LIST_VIOLATION; } // 5. Look up the value — variable-length; missing returns SLOAD_MISSING let value_bytes = match caller.data().state_get(&slot_bytes) { Some(bytes) => bytes, None => return -1, // SLOAD_MISSING }; let actual_len = value_bytes.len() as i32; // 6. Charge per-byte gas based on what we'll copy to the caller let to_copy = actual_len.min(out_max_len.max(0)) as usize; if caller.consume_fuel(to_copy as u64).is_err() { return ERR_OUT_OF_GAS; } // 7. Write back to WASM memory (truncated to out_max_len) if memory.write(&mut caller, out_ptr as usize, &value_bytes[..to_copy]).is_err() { return ERR_INVALID_INPUT; } actual_len // contract sees the true length even if truncated } }
The flow when a contract executes sload(slot_ptr, value_out_ptr):
Contract WASM (any language) wasm-exec (Rust)
───────────────────────────── ───────────────────────────────
[author's compiled WASM] [engine startup, once per node]
(import "pyde" "sload" ...) linker.func_wrap("pyde", "sload",
host_sload)
↓
[at instantiation] [wasmtime walks contract's
import section, binds each
import to a linker entry]
↓
[at execution] [contract's `sload` stub now
sload(slot_ptr, value_out_ptr) points to host_sload]
│
▼
[wasmtime traps into Rust] ──────→ host_sload(caller, slot_ptr, value_out_ptr)
│
├─ charge gas via consume_fuel
├─ read 32 bytes from WASM memory at slot_ptr
├─ access-list check
├─ state_get(slot_bytes).unwrap_or([0; 32])
├─ write 32 bytes back at value_out_ptr
▼
← return i32 return 0
│
▼
[contract resumes execution
with sload's return value]
13. Cross-contract call mechanics
cross_call is the most complex host function. This section spells out the exact flow when contract A calls contract B.
13.1 The 12-step flow
When A invokes cross_call(B_addr, "fn_name", calldata, value, gas_limit, return_data_out_ptr, return_data_out_len_ptr):
- Wasmtime traps into
host_cross_callwith all arguments. - Charge A's gas:
1,000 base + 8 × calldata_len + (gas_limit reserved). If A's remaining budget is insufficient, trap A withOutOfFuel. - Validate target B: state-lookup
B_addr; must have a non-emptycode_hash. If not, returnERR_CROSS_CALL_FAILED. - Validate function name: lookup
"fn_name"in B's deployed ABI metadata (cached at deploy time). If not found, returnERR_INVALID_FUNCTION_NAME. - Reentrancy check: walk the current call stack of
(contract, fn)pairs. If(B_addr, "fn_name")is already on the stack AND"fn_name"is not#[reentrant], returnERR_REENTRANCY_BLOCKED. - Payable check: if
value > 0and"fn_name"is not#[payable], returnERR_VALUE_TRANSFER_NOT_PAYABLE. - Push a new overlay onto the per-tx overlay stack. Call it
overlay_B. Reads from B'ssloadwalk:overlay_B → overlay_A → dashmap → state_cf. Writes from B'ssstorego tooverlay_Bonly. - Create a new wasmtime Store + Instance for B with: fresh linear memory (B cannot see A's memory directly); fuel =
gas_limit; the sameLinker(so B has the same host functions available);HostStatepointing tooverlay_Band the active call stack with B pushed on. - Copy calldata from A's memory into B's memory at a host-chosen offset (typically the start of B's memory's calldata region).
- Apply value transfer: if
value > 0, atomically debit A's balance and credit B's byvalue. This happens before B's code runs so B's firsttx_value()call sees the right amount. - Invoke B's entry function with calldata. B's WASM executes in isolation — its
sload/sstoreoperate onoverlay_B; its owncross_callwould push another overlay on top. - On B's exit, handle the outcome:
- Success (B returned normally): merge
overlay_Bintooverlay_A; copy return data from B's memory into A's memory atreturn_data_out_ptr; write actual length atreturn_data_out_len_ptr; consume B's actual fuel from A's remaining budget; return0to A. - Trap (B hit OutOfFuel, MemoryOutOfBounds, reverted, etc.): discard
overlay_Bentirely; revert the value transfer from step 10; consume B's actual fuel from A's remaining; returnERR_CROSS_CALL_FAILEDto A. - OutOfFuel specifically: same as trap, but return
ERR_CROSS_CALL_OUT_OF_GASto distinguish.
- Success (B returned normally): merge
13.2 The overlay stack
The per-tx overlay stack is the load-bearing data structure here:
At depth 0 (top of stack — what B's writes go to):
overlay_B = HashMap<SlotHash, Value> (initially empty)
At depth 1:
overlay_A = HashMap<SlotHash, Value> (A's pending writes from before cross_call)
At depth 2:
wave overlay = HashMap<SlotHash, Value> (writes from prior committed txs in this wave)
At depth 3:
dashmap (write-back cache, hot recent state)
At depth 4:
state_cf (canonical disk-backed state)
At depth 5:
jmt_cf (versioned tree; only for state-root computation)
Reads walk top-down until a value is found. Writes always go to the top of the stack. Merge on success copies overlay_B's entries into overlay_A. Discard on trap drops overlay_B.
This is the same nesting pattern at every depth: a tx that issues a cross_call becomes one frame deeper; that sub-call issuing another cross_call becomes one deeper still.
13.3 Memory isolation
A and B have completely separate WASM linear memories. They cannot see each other's memory. The only communication channels are:
- A → B: the calldata bytes copied at step 9
- B → A: the return data copied at step 12 (success path)
- A ↔ B (shared): state, but only through the overlay stack — there is no shared memory region
This means a malicious B cannot read A's stack, A's locals, A's other variables. The sandbox is per-instance.
13.4 Stack depth cap
To prevent runaway recursion (e.g., a contract that calls itself unboundedly through different addresses), the call stack has a hard depth limit. Default: 1024 frames. Exceeding it returns ERR_CROSS_CALL_FAILED from the offending cross_call invocation.
13.5 Gas accounting
- Reservation: A pre-charges
gas_limitfrom its remaining budget at step 2 (the host function refuses to start the sub-call if A can't afford the reservation). - Forwarding: B receives a fresh fuel counter of
gas_limit. - Consumption: After B exits, A's budget is debited by B's actual fuel consumed (which may be less than
gas_limit). - No refund: any unused portion of
gas_limitis not returned to A (consistent with the no-refund policy). A consumed gas it didn't end up using — that's the tradeoff for the simpler accounting model. Authors are advised to sizegas_limitcarefully.
13.6 Why cross_call_static exists
cross_call_static is the read-only variant. It enforces:
- Target function must be marked
#[view]— if not, returnsERR_FORBIDDEN. - Sub-call cannot mutate state, emit events, or transfer value (the view-mode flag in the overlay rejects writes).
- No new overlay is needed (no writes possible); reads walk the existing stack.
This is cheaper (no overlay push/merge) and safer (no reentrancy risk — view functions can't change anything observable).
14. Event encoding convention
Each event carries 1 to 4 topics (each 32 bytes) plus an opaque data payload. The chain stores both verbatim. For wallets, indexers, and SDKs to decode events consistently, Pyde defines a canonical convention for both.
14.1 Topics
Topics are how events are indexed and filtered on-chain. Each event has 1 to 4 topics. By convention:
topic[0]is alwaysBlake3(canonical_event_signature). This is the event-type identifier — what subscribers and indexers match on as the primary filter.topic[1..topics_count]are indexed-field values, in author-declared order.
Authors mark fields as indexed in otigen.toml:
[events.Transfer]
signature = "Transfer(address,address,uint128)"
fields = [
{ name = "from", type = "address", indexed = true },
{ name = "to", type = "address", indexed = true },
{ name = "amount", type = "uint128" }, # not indexed → goes in data
]
Up to 3 fields can be indexed (giving a total of 4 topics — signature plus 3 — matching EVM's LOG4 limit).
Topic value encoding
How each indexed-field value becomes a 32-byte topic:
| Field type | Encoding rule |
|---|---|
address ([u8; 32]) | Stored as-is (already 32 bytes) |
uint64, int64 | Left-padded to 32 bytes (zeros in MSB) |
uint128, int128 | Left-padded to 32 bytes |
bool | Left-padded to 32 bytes (0x00...00 or 0x00...01) |
[u8; N] where N ≤ 32 | Left-padded to 32 bytes |
string | Blake3(utf8_bytes) |
bytes (Vec<u8>) | Blake3(bytes) |
T[] (Vec<T>) | Blake3(borsh_encode(value)) |
struct { ... } | Blake3(borsh_encode(value)) |
enum { ... } | Blake3(borsh_encode(value)) |
Rule: fixed-size ≤32 bytes get stored as-is (padded); variable-size or >32 bytes get hashed. Matches EVM's indexed semantics.
Canonical signature string
The signature string drives topic[0]. Type names mirror Solidity's for familiarity:
| Pyde type | Signature token |
|---|---|
[u8; 32] (address) | address |
u64 | uint64 |
u128 | uint128 |
i64 | int64 |
bool | bool |
String (UTF-8) | string |
Vec<u8> | bytes |
Vec<T> | T[] |
[T; N] | T[N] |
enum X { ... } | enum |
| Custom struct | tuple (with field types in parens; rare) |
Examples:
"Transfer(address,address,uint128)"
"Approval(address,address,uint128,uint64)"
"OrderFilled(address,string,uint128,uint64[],enum)"
The signature string is not stored on chain — only Blake3(signature) is, as topic[0]. Indexers and SDKs maintain a registry of signatures they care about and hash them locally to match against event topics. The pyde.abi custom section of the deployed contract carries the full signature for any explorer that wants to render the event with field names.
14.2 Data
14.2 Data
The data field is the event payload as bytes. The chain stores it verbatim — encoding is the author's choice.
Borsh is the recommended encoding. Pyde's toolchain, SDKs, indexers, wallets, and example contracts all assume Borsh by default; choosing it gets you out-of-the-box decoding everywhere. otigen ships Borsh helpers as part of the canonical project templates. pyde-rust-sdk and pyde-ts-sdk ship Borsh decoders that match topics to signature registries and auto-deserialize. Block explorers built on these SDKs render Borsh-encoded events without any per-contract integration.
Authors picking a different encoding (raw bytes for tiny events, Protobuf for cross-team contracts, custom format for niche cases) are free to do so — the chain doesn't care — but they take on the integration burden: SDK consumers need custom decoders, wallet previews can't auto-render the event, indexers need per-contract logic.
Borsh chosen as the recommended default over alternatives:
- vs JSON: smaller (no whitespace, no field names in the wire format), deterministic byte ordering, no integer-precision issues
- vs Protobuf: simpler, no schema-evolution complexity, language-agnostic implementations more uniform, no
.prototoolchain dependency - vs SCALE: better Rust-ecosystem support, simpler grammar
- vs EVM ABI encoding: simpler, more compact, no padding-to-32-bytes overhead, no special handling for dynamic-length fields
- vs MsgPack/CBOR: deterministic by construction (canonical encoding), no implementation-defined behaviors
Borsh is supported in: Rust (borsh crate), TypeScript (@dao-xyz/borsh-ts, borsh-js), AssemblyScript (community as-borsh), Go (github.com/near/borsh-go), C (community), Python (borsh-construct). Pyde's recommendation tracks this ecosystem; if a language gains a high-quality Borsh implementation, contracts in that language get first-class event support without Pyde shipping bindings.
14.3 Example: Rust emitter (with indexed fields)
The author declares the event in otigen.toml (per §14.1). The SDK generates a typed emit helper. The author's code stays clean:
#![allow(unused)] fn main() { use pyde_contract::events; // Inside a contract function: events::Transfer { from: caller_address, to: recipient, amount: 100u128, }.emit(); }
Behind the scenes, the SDK helper (generated from otigen.toml) builds the call:
#![allow(unused)] fn main() { // Generated by SDK from otigen.toml — author doesn't write this impl Transfer { pub fn emit(self) -> i32 { // 1. Build topics let mut topics = [0u8; 4 * 32]; // topic[0] = Blake3(signature) — precomputed constant topics[0..32].copy_from_slice(&TRANSFER_SIGNATURE_HASH); // topic[1] = padded(from) — address is already 32 bytes topics[32..64].copy_from_slice(&self.from); // topic[2] = padded(to) topics[64..96].copy_from_slice(&self.to); // No topic[3] — we only have 2 indexed fields. // 2. Borsh-encode non-indexed fields (just amount) let data = borsh::to_vec(&self.amount).unwrap(); // 3. Call the host function unsafe { emit_event( topics.as_ptr() as u32, 3, // topics_count = 3 data.as_ptr() as u32, data.len() as u32, ) } } } // Precomputed at otigen build time: const TRANSFER_SIGNATURE_HASH: [u8; 32] = blake3_const(b"Transfer(address,address,uint128)"); }
For events without indexed fields, the SDK emits with topics_count = 1 (just the signature hash) and Borsh-encodes all fields into data.
14.4 Example: TypeScript decoder (in pyde-ts-sdk, with indexed fields)
import { deserialize } from "@dao-xyz/borsh-ts";
import { blake3 } from "@noble/hashes/blake3";
// Borsh schema only needs the NON-indexed fields:
class TransferEventData {
amount: bigint; // u128
}
const transferTopic = blake3("Transfer(address,address,uint128)");
for await (const event of subscription) {
// Match by signature hash at topic[0]
if (!uint8ArrayEqual(event.topics[0], transferTopic)) continue;
// Indexed fields come from topics[1..]:
const from = event.topics[1]; // 32-byte address (no padding for addresses)
const to = event.topics[2];
// Non-indexed fields come from Borsh-decoded data:
const { amount } = deserialize(event.data, TransferEventData);
console.log(`Transfer from ${hex(from)} to ${hex(to)} amount ${amount}`);
}
A wallet or explorer that doesn't statically know the event type can still decode it dynamically:
- Fetch the contract's
.wasmviapyde_getContractCode(addr) - Parse the
pyde.abicustom section to find the event matchingtopics[0] - The ABI declares which fields are indexed (→ pair them with
topics[1..]) and which are not (→ Borsh-decode them fromdata) - Render the typed event with field names and values
14.5 Authors are free to use a different encoding
The data field is opaque to the chain. An author who has reason to use a custom encoding (raw bytes for ultra-simple events, Protobuf for cross-team consistency, etc.) is free to do so. The cost: SDK consumers must write custom decoders for those events; standard wallet preview / explorer tooling won't auto-decode them.
The recommendation stands: use Borsh unless you have a specific reason not to.
15. Event storage, indexing, and subscriptions
This section specifies how events emitted via pyde::emit_event (§7.5) and pyde::parachain_emit_event (§8.3) are committed on-chain, stored at each node, indexed for query, and delivered to real-time subscribers.
15.1 Per-overlay buffering during execution
Each per-tx overlay (see §3 of Chapter 3) maintains its own ordered events buffer alongside its state writes. Calls to emit_event append to the current top-of-stack overlay's buffer.
On overlay merge (success): parent.events.extend(child.events)
On overlay discard (revert): child.events dropped along with state writes
This means: events from a reverted (sub-)call are not committed. A top-level tx that reverts emits zero events. A cross_call'd sub-call that traps loses its events when its overlay is discarded; if the parent then succeeds, only the parent's pre-call events plus its post-call events (if any) survive.
The wave's final events list = the topmost overlay's events buffer at wave commit time, with positions assigned as (wave_id, tx_index, event_index) in canonical order.
15.2 On-chain commitment
Every wave commit record includes both an events_root (deterministic Merkle commitment) and an events_bloom (probabilistic summary).
#![allow(unused)] fn main() { struct WaveCommitRecord { wave_id: u64, anchor_hash: VertexHash, state_root: (Blake3Hash, Poseidon2Hash), // unchanged from Ch 4 events_root: Blake3Hash, // NEW: see §15.2.1 events_bloom: [u8; 256], // NEW: 2048-bit, see §15.2.2 included_txs: Vec<TxHash>, tx_count: u32, events_count: u32, // total events in this wave gas_used: u128, } }
The wave commit record is what the committee threshold-signs as part of the HardFinalityCert. events_root and events_bloom therefore inherit consensus-level integrity.
15.2.1 events_root
A binary Merkle tree over the wave's events in canonical order:
leaf_i = Blake3(borsh_encode(EventRecord_i))
node = Blake3(left || right)
events_root = top of tree (padded with zero-leaves to next power of two)
For a wave with zero events:
events_root = [0u8; 32] (sentinel — no events to commit)
Light client inclusion proof: to prove "event E was emitted in wave W", a light client needs:
- The wave's
HardFinalityCertcontaining the signedevents_root. - The
EventRecorditself. - A Merkle proof from the event's leaf position to the root (log₂(events_count) hashes).
Proof verification: recompute the leaf hash, walk the proof to reconstruct the root, compare against the cert's events_root. If equal, the event is provably committed to that wave.
Cost per event ~32-byte hash; cost per wave ~few hundred μs (events_count is typically thousands at most, not millions). Negligible compared to wave-commit fixed costs.
Future ZK extension: v2 may add a events_root_poseidon2 parallel field for ZK-circuit-friendly proofs, mirroring the dual-hash state-root pattern (Chapter 4 §4.1b). v1 ships Blake3 only.
15.2.2 events_bloom
A 256-byte (2048-bit) bloom filter over the wave's events. Used for cheap "did any event matching X happen in wave W?" queries without fetching the event list.
For each event in the wave:
for each topic in event.topics: // 1 to 4 topics per event
insert(bloom, topic)
insert(bloom, event.contract_addr) // 32-byte contract address
insert(bloom, item):
h1 = blake3(item)[..8] mod 2048
h2 = blake3(item)[8..16] mod 2048
h3 = blake3(item)[16..24] mod 2048
bloom.set_bit(h1)
bloom.set_bit(h2)
bloom.set_bit(h3)
Three hash functions, 2048-bit filter. Expected false-positive rate at typical wave loads:
| Events per wave | False-positive rate |
|---|---|
| 100 | ~0.001 % |
| 1,000 | ~1 % |
| 5,000 | ~17 % |
| 10,000 | ~52 % |
At the v1 honest throughput target (most txs not emitting events), a typical wave has <2,000 events and the bloom is highly selective. At peak load it becomes less useful but never lies (no false negatives). Historical query (§15.4) uses the bloom as a pre-filter and the indexes for exact matches.
15.3 Per-node storage layout
Three RocksDB column families. Big-endian numeric encoding throughout so RocksDB's lexicographic iterator order matches numeric order.
events_cf (primary store)
key: wave_id (8 BE) || tx_index (4 BE) || event_index (4 BE)
value: borsh_encode(EventRecord)
EventRecord {
wave_id: u64,
tx_index: u32,
event_index: u32,
contract_addr: [u8; 32],
topics: Vec<[u8; 32]>, // 1 to 4 topics; topic[0] = signature hash
data: Vec<u8>,
}
events_by_topic_cf (index)
key: topic (32) || wave_id (8 BE) || tx_index (4 BE) || event_index (4 BE)
value: () // empty — the key contains all the lookup info
Prefix scan with topic_X → all events whose ANY topic equals X, in wave order.
An event with N topics writes N rows to this CF (one per topic value).
events_by_contract_cf (index)
key: contract_addr (32) || wave_id (8 BE) || tx_index (4 BE) || event_index (4 BE)
value: ()
Prefix scan with contract_X → all events from that contract, in wave order.
Atomicity: on every wave commit, the engine writes one RocksDB WriteBatch containing all three CFs' updates plus the wave commit record. Atomic: either all three indexes update together or none does.
Write cost per event: 1 + topics_count + 1 RocksDB puts — one primary, one per topic, one contract index. At sustained ~2,000 events/wave with an average of ~2 topics each, that's ~8,000 puts/wave, which RocksDB handles in single-digit ms with the existing PIP-4 write-back cache architecture (Chapter 4).
15.4 Historical query
JSON-RPC method pyde_getLogs(filter):
#![allow(unused)] fn main() { struct GetLogsRequest { from_wave: u64, // inclusive to_wave: u64, // inclusive; capped: to_wave - from_wave ≤ 5,000 topics: [Option<Vec<[u8;32]>>; 4], // positional filter; index i matches event.topics[i]. // Some(list) at position i: event's i-th topic must be IN the list // None at position i: any value at that position (or absent) contract: Option<[u8; 32]>, // None = any contract cursor: Option<EventCursor>, // continuation from prior page; None = start fresh limit: u32, // max events to return; default 100, max 1,000 } struct EventCursor { wave_id: u64, tx_index: u32, event_index: u32, } struct GetLogsResponse { events: Vec<EventRecord>, next_cursor: Option<EventCursor>, // None = exhausted; Some = call again with this cursor } }
Filter semantics (positional, EVM-style):
match(event, filter) =
(filter.contract == None OR event.contract_addr == filter.contract) AND
(filter.from_wave == None OR event.wave_id >= filter.from_wave) AND
for each position i in 0..4:
if filter.topics[i] == None: skip (any value matches)
else if event.topics.len() <= i: NOT a match (event missing this position)
else: event.topics[i] must be IN filter.topics[i] (OR-list within a position)
Examples:
# "All Transfer events":
filter.topics = [Some([Blake3("Transfer(address,address,uint128)")]), None, None, None]
# "All Transfer events FROM address 0xAB...CD":
filter.topics = [
Some([Blake3("Transfer(...)")]),
Some([padded(0xAB...CD)]),
None,
None,
]
# "Either Transfer OR Approval from contract X":
filter.topics = [
Some([Blake3("Transfer(...)"), Blake3("Approval(...)")]),
None, None, None,
]
filter.contract = Some(contract_X)
Query plan:
- Validate the request:
to_wave - from_wave ≤ 5,000; per-position list size ≤ 8;limit ≤ 1,000. - Wave-level bloom prefilter: for each wave in
[from_wave, to_wave], load the wave's commit record and test theevents_bloomagainst every concrete value in the filter (any positional topic OR the contract). Drop waves with no bloom hit. - Per-wave exact lookup: for surviving waves, pick the most selective filter element to drive the scan:
- If a specific position has a single topic value: scan
events_by_topic_cffor that value, then post-filter results against the remaining positional constraints + contract. - If no topic but contract is set: scan
events_by_contract_cfprefixcontract || wave_id, then post-filter against topic positions. - If multiple values at one position: scan each, merge sorted union.
- If a specific position has a single topic value: scan
- Stream results in canonical order until
limitis reached, buildingnext_cursorto point to the next event past the limit. - Return the page + cursor.
Subsequent pages: client calls pyde_getLogs again with the same filter and the returned cursor. Server resumes scanning past the cursor.
Ordering is wave-ascending only in v1. Descending order is a v2 minor bump if needed.
15.5 Real-time subscription
JSON-RPC method pyde_subscribe({method: "logs", filter}) over WebSocket:
#![allow(unused)] fn main() { struct LogSubscription { topics: [Option<Vec<[u8;32]>>; 4], // positional filter (same shape as pyde_getLogs) contract: Option<[u8; 32]>, from: Option<EventCursor>, // for resume-on-reconnect; None = live from now } }
Engine behavior:
- On subscribe: add
(subscription_id, LogSubscription)to in-memory registry; iffromis provided, replay from disk via the historical-query machinery until caught up to the current wave, then transition to live. - On every wave commit (after the wave's events land in disk): for each active subscription, walk the wave's events, match against the filter, push matches as
LogEventNotificationrecords over the WebSocket. - On disconnect: drop subscription from registry. Subscriber must
pyde_subscribeagain on reconnect (withfromcursor if it wants to resume from a specific position).
#![allow(unused)] fn main() { struct LogEventNotification { subscription_id: SubscriptionId, event: EventRecord, // includes (wave_id, tx_index, event_index) for dedup } }
Delivery guarantees:
- Post-commit only. Subscribers receive events only after the event's wave has committed. No "pending event" notifications.
- Canonical order. Events arrive in
(wave_id, tx_index, event_index)order. Subscribers can dedupe by cursor since each event carries its position. - At-least-once. If the WebSocket disconnects mid-push, the subscriber must reconnect and use
fromcursor to resume from a known-processed position. The engine does not track which events a specific subscriber acknowledged; subscribers reconcile via cursor.
Filter syntax (positional, EVM-style): identical to pyde_getLogs (§15.4). Per-position topic constraints are AND'd; within each position, multiple values are OR'd; the contract filter is AND'd on top.
This covers EVM-equivalent filtering ("Transfer events from address X to anyone", "Approval OR Transfer events on token Y", etc.) and gives indexers parity with what they're used to.
15.6 Retention
Events follow the same retention tiering as state (Chapter 4):
| Node tier | Events retention |
|---|---|
| Archive | Forever |
| Full node | Last 90 days |
| Committee validator | Last 30 days |
| Light client | No primary storage; verifies inclusion proofs against signed events_root |
Pruning: at every epoch boundary, the engine sweeps events_cf, events_by_topic_cf, and events_by_contract_cf together, removing entries with wave_id < (current_wave - retention_waves). Lockstep — never partial. The wave commit records themselves are retained per the wave-commit retention policy (longer than events; needed for chain-of-trust during state sync).
15.7 Light client model
A light client doesn't store events. It can:
- Verify a specific event exists: given an
EventRecord(fetched from any full node) plus the wave'sHardFinalityCertplus a Merkle proof toevents_root, verify the event is committed to a finalised wave. - Probabilistically check existence: given just the wave's
HardFinalityCert, checkevents_bloomfor a topic/contract match. False-positive rate per §15.2.2. - Subscribe to live events: connect to a full node's
pyde_subscribe. Trust the node's stream (or verify each event with an inclusion proof for high-stakes cases).
15.8 Cross-parachain event isolation
Events from parachain_emit_event (§8.3) are recorded with the parachain's parachain_id in their contract_addr field (parachains and contracts share the address space; see PARACHAIN_DESIGN.md §4). Subscribers filter on contract_addr = parachain_id to listen for a specific parachain's events.
No separate parachain-events column family — they share the same events_cf / events_by_topic_cf / events_by_contract_cf machinery as ordinary contract events. The bloom filter aggregates both. The Merkle root commits to both. Parachain events are queryable identically.
15.9 Implementation notes for wasm-exec
Reference flow for the engine implementation (pseudocode):
#![allow(unused)] fn main() { // During tx execution fn host_emit_event( mut caller: Caller<'_, HostState>, topics_ptr: i32, topics_count: i32, data_ptr: i32, data_len: i32, ) -> i32 { // 1. Validate + gas if topics_count < 1 || topics_count > 4 { return ERR_INVALID_INPUT; } if data_len > MAX_EVENT_DATA_SIZE { return ERR_INVALID_INPUT; } let gas = EMIT_EVENT_BASE_GAS + 50 * topics_count as u64 + 8 * data_len as u64; if caller.consume_fuel(gas).is_err() { return ERR_OUT_OF_GAS; } if caller.data().view_mode { return ERR_FORBIDDEN; } // 2. Read topics + data from WASM memory let memory = /* get exported memory */; let total_topic_bytes = (topics_count as usize) * 32; let mut topics_buf = vec![0u8; total_topic_bytes]; memory.read(&caller, topics_ptr as usize, &mut topics_buf)?; let topics: Vec<[u8; 32]> = topics_buf .chunks_exact(32) .map(|c| { let mut t = [0u8; 32]; t.copy_from_slice(c); t }) .collect(); let mut data = vec![0u8; data_len as usize]; memory.read(&caller, data_ptr as usize, &mut data)?; // 3. Append to the current overlay's events buffer let event = EventRecord { wave_id: caller.data().current_wave, tx_index: caller.data().tx_index, event_index: caller.data().overlay_top().events.len() as u32, contract_addr: caller.data().self_address, topics, data, }; caller.data_mut().overlay_top_mut().events.push(event); 0 } // At wave commit fn finalize_wave_events(wave: &mut WaveCommit) { let all_events = wave.collect_committed_events(); // walks committed overlays wave.events_count = all_events.len() as u32; // Build bloom — every topic + contract_addr of every event let mut bloom = [0u8; 256]; for e in &all_events { for topic in &e.topics { bloom_insert(&mut bloom, topic); } bloom_insert(&mut bloom, &e.contract_addr); } wave.events_bloom = bloom; // Build Merkle root over canonical-ordered events let leaves: Vec<Blake3Hash> = all_events.iter() .map(|e| blake3_hash(&borsh::to_vec(e).unwrap())) .collect(); wave.events_root = merkle_root_blake3(&leaves); // Write to disk (atomic batch with state + wave commit) let mut batch = WriteBatch::new(); for e in all_events { let primary_key = (e.wave_id, e.tx_index, e.event_index).encode_be(); batch.put_cf(events_cf, primary_key, borsh::to_vec(&e).unwrap()); // One row per topic in events_by_topic_cf for topic in &e.topics { let topic_key = (topic, e.wave_id, e.tx_index, e.event_index).encode_be(); batch.put_cf(events_by_topic_cf, topic_key, &[]); } let contract_key = (e.contract_addr, e.wave_id, e.tx_index, e.event_index).encode_be(); batch.put_cf(events_by_contract_cf, contract_key, &[]); } db.write(batch).expect("atomic events write"); // Notify subscribers (positional filter match per §15.5) for (sub_id, sub) in subscription_registry.iter() { for e in &wave.events { if matches(e, &sub.filter) { websocket_push(sub_id, LogEventNotification { subscription_id: sub_id, event: e.clone() }); } } } } }
15.10 Open items deferred to v2
- Address-list filters. v1 supports one contract per subscription. v2 could allow
contracts: Vec<Address>(OR-list of contracts). - Descending wave queries. v1 returns events ascending only. v2 could add
direction: Ascending | Descending. - events_root_poseidon2. ZK-friendly parallel root for the events tree, mirroring the dual-hash state-root pattern. v2 work; not on v1 critical path.
- Indexed wildcards / set matching on contract. v1 contract filter is a single optional address. v2 could allow set membership and contract-name pattern matching.
Note: multi-topic native (up to 4 topics per event with EVM-style indexed-field marking) ships at v1 — see §14.1 for the encoding and §15.3-§15.5 for storage / query / subscription.
16. Conformance test surface
A conformance test suite — implementation of which is post-mainnet hardening work — must exercise every function in §7 and §8 with:
- Valid inputs returning expected outputs
- Each error code's trigger condition
- Each gas cost (charged before execution begins)
- Memory bounds at the WASM limits (0, 1, 64 MB - 1, 64 MB boundary)
- Each forbidden-import case at deploy time
- Determinism: run the same input on 128 simulated validators; outputs must match bit-for-bit
The conformance test suite ships in the post-pivot engine repo under wasm-exec/tests/conformance/. It is run as part of CI on every wasm-exec commit and as a gate on protocol upgrades that touch this spec.
17. Evolution & deprecation policy
17.1 Adding a new function (minor version bump)
- PIP describing the new function: signature, semantics, gas cost, error codes, use case.
- PIP review + acceptance per Chapter 15 — Governance.
- Engine implements the function under a
pyde_abi_v1_<N+1>feature gate. - New function is callable only by modules declaring
pyde_abi_version >= 1.(N+1). - Modules built against earlier versions continue executing unchanged.
17.2 Changing existing function semantics (NOT permitted)
Existing function semantics, gas costs, and error codes are frozen at v1.0 mainnet. Any change requires a v2.0 major bump, which is a hard fork.
If a v1.x function is discovered to have an implementation bug that diverges from this spec, the engine is patched to match the spec. If a v1.x function is discovered to have a spec bug (the spec itself is wrong), the spec is amended, the engine is patched to match the corrected spec, and the change is documented in the Migration Notes as a clarification (not a new function and not a major bump).
17.3 Reserving for v2
Functions known to be useful but requiring substantial design work (e.g., a streaming I/O abstraction, an account-abstraction policy invocation primitive, session-key authorization hooks) are not added to v1. They are reserved for v2 under "Beyond V1" and ship when ready.
17.4 Per-language SDK alignment
Pyde ships two first-party SDKs:
pyde-rust-sdk— host-side authoring substrate, used by#[pyde::entry]/pyde::declare_storage!()/pyde::declare_events!()to hide this ABI from contract authors. Vendored viapyde-host.pyde-ts-sdk— client-side (browser / Node) for talking to a Pyde node. Pure-language SDK like ethers v6; not a contract-author surface.
Contract-side bindings for AssemblyScript, Go (TinyGo), and C/C++ are community-maintained against this spec. Each binding library translates this spec's WAT signatures into idiomatic language-native function declarations; the canonical example projects under otigen/examples/counter-{go,as,c}/ demonstrate the expected wrapping for each language.
18. References
- Chapter 3 — Execution Layer — conceptual overview, wasmtime config, per-tx overlay model
- Chapter 5 — Otigen Toolchain — how authors declare host imports in their language of choice
- Chapter 10 — Gas and Fee Model — fuel-to-gas mapping, EIP-1559, no-refund policy
- Chapter 13 — Parachains — parachain framework overview
- companion/PARACHAIN_DESIGN.md — full parachain design + ABI extension rationale
- companion/PERFORMANCE_HARNESS.md — gas-table calibration authority
- companion/THREAT_MODEL.md — security review of every host function
- WebAssembly Core Specification — the WASM ISA itself
- wasmtime documentation — the runtime Pyde uses
Document version: 0.1 (draft for v1 mainnet)
License: See repository root
WASM Contract Author Guide
Version: v1.0 (draft) Status: Companion to HOST_FN_ABI_SPEC.md. Pedagogical / authoring reference. Non-normative — when this guide and the ABI spec disagree, the spec wins.
Applies equally to smart contracts and parachains. This guide describes the WASM-level patterns a Pyde author must understand to write any on-chain code. The same patterns apply identically to:
- Base-chain smart contracts deployed via
otigen(type = "contract")- Parachain modules deployed via
otigen(type = "parachain")Parachains are simply WASM modules with an extended host-function allowlist (see PARACHAIN_DESIGN.md §11 and HOST_FN_ABI_SPEC §8). The boundary mechanics — value types, linear memory, pointer + length conventions, byte staging, host-side reads — are identical in both contexts.
Why this guide exists
Pyde does not ship a maintained per-language SDK. The contract surface is a WASM ABI plus a bundling CLI (otigen) plus canonical examples — nothing more. Authors compile their own WASM in any wasm32-target language, declare host imports manually, and stage bytes into linear memory themselves.
That design keeps the chain's surface minimal and audit-friendly, but it pushes more responsibility onto the author. This guide is the conceptual bridge between the formal HOST_FN_ABI_SPEC (which is normative but terse) and the working code in otigen/examples/.
If you only read one section: §5 (host-fn declarations), §7 (field-keyed storage), §8 (cross-contract calls), §9 (FALCON-512 verification), and §10 (upgradeable proxy pattern) cover 90% of the patterns a real contract needs.
Rust authors: the macro substrate
For Rust contracts, the pyde-host crate ships every host fn declared in this guide, and the function-like macros #[pyde::entry], pyde::declare_storage!(), and pyde::declare_events!() collapse the boilerplate every section below walks through:
#[pyde::entry]wraps a user fn with the calldata-decode + return-encode shim required by Pyde's() -> ()entry-point ABI (HOST_FN_ABI_SPEC §3.0). Authors writefn transfer(to: Address, amount: u128) -> bool { ... }; the macro emits the siblingextern "C" fn transfer()plus the wasm-side calldata marshalling.pyde::declare_storage!()reads[state]fromotigen.tomlat compile time and emits typed accessors (storage::balances().read(&owner),storage::balances().write(&owner, amount)) that delegate to the chain's typed-storage host fns (sstore_scalar/sload_scalar/sstore_map1…map3). Field-type vocabulary:u8…u128,i8…i128,bool,address,hash32,bytes,string,vec(<fixed-width-inner>),struct(<Name>)— seeOTIGEN_BINARY_SPEC §4.6for the full table.pyde::declare_events!()reads[events.*]blocks, computesBlake3(canonical_signature)for topic-0 at expansion time, emits typed structs with.emit()— no manual topic buffer arithmetic.
Rust contracts on the macro substrate (the default since the substrate batch — see examples/erc20-token/ for a canonical reference) skip §5 (host-fn declarations), §6 (staging buffers), and most of §7 (slot derivation) — the macros generate all of it. The patterns in §8 / §9 / §10 still apply because cross-contract calls / FALCON-verify / delegate_call proxies have author-side logic that no macro can ship.
This guide describes the raw WASM-ABI pattern. The raw pattern stays fully supported and is the right shape for:
- Non-Rust contract authors (TinyGo, AssemblyScript, C — the macros are Rust-only).
- Community SDK porters targeting other languages — see
SDK_AUTHOR_GUIDE.mdfor the bar a community SDK needs to clear. - Rust authors who need full control over slot derivation (e.g. matching another chain's layout) or who want to understand what the macros emit before depending on them.
Read this guide top-to-bottom to learn the WASM ABI at the metal. Then, if you're writing Rust, drop into the macro substrate via examples/counter-rust/ and the substrate batch's other Rust examples.
1. The WASM type model
1.1 Value types at the function boundary
The WebAssembly core specification defines exactly five value types that can appear in function signatures crossing the WASM module boundary:
| WASM value type | Bits | What it represents |
|---|---|---|
i32 | 32 | Signed or unsigned 32-bit integer. Also serves as the type for linear-memory pointers since Pyde uses the wasm32 address space. |
i64 | 64 | Signed or unsigned 64-bit integer. Used for gas budgets, timestamps, block heights, the low/high halves of u128. |
f32 | 32 | IEEE-754 single-precision float. Discouraged in contracts — floating-point determinism across NaN encodings is fragile. |
f64 | 64 | IEEE-754 double-precision float. Discouraged in contracts — same caveat. |
v128 | 128 | SIMD vector. Disabled in Pyde (config.wasm_simd(false) per Chapter 3 §3.2). |
That is the entire universe of types that can appear in the parameter list or return position of a function crossing the host ⇄ contract boundary. There are also reference types (externref, funcref) in the WASM spec, but they are also disabled in Pyde for the same determinism / footprint reasons SIMD is disabled.
Practical implication: any time you want to pass a 32-byte address, a 16-byte u128 balance, a string, a struct, or a variable-length blob across the boundary, you decompose it into the four primitives + pointer-into-linear-memory patterns described in §4.
1.2 Internal types (Rust / Go / AS) are unrestricted
Inside the body of a function, between the open and close braces, the WASM-primitive restriction does not apply. The compiler is free to use whatever the source language supports:
#![allow(unused)] fn main() { // EXPORT — Pyde mandates void-void entries (HOST_FN_ABI §3.5.2). The // function signature that crosses the boundary takes no parameters and // returns nothing; inputs are pulled from the calldata host fns // (`pyde::calldata_size` + `pyde::calldata_copy`), outputs go through // `pyde::return`. The `#[pyde::entry]` macro emits this shim automatically. #[pyde::entry] fn example_export() -> u128 { // INSIDE the function body — arbitrary Rust. The compiler will lower // these to WASM stack manipulation, linear-memory loads/stores, and // arithmetic instructions. Nothing crosses the module boundary here. let nums: [u128; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; let sum: u128 = nums.iter().sum(); // The macro's emitted shim borsh-encodes the return value and writes // it via `pyde::return(out_ptr, out_len)`. The WASM-visible export // is still `() -> ()`. sum } }
The same holds for AssemblyScript (classes, arrays, strings — all fine internally), TinyGo (structs, slices, maps — all fine internally), and C (structs, unions, function pointers — all fine internally). The only constraint is on the surface that the WASM runtime sees.
1.3 Why the restriction exists
The WASM core specification is intentionally minimal. It exists to be a portable, sandboxed, verifiable bytecode format. Every type at the boundary adds complexity to:
- The validator (must check well-formedness)
- The compiler backend (must lower the type to native code deterministically)
- The host (must marshal the type across the FFI)
By restricting boundary types to a tiny set of primitives, WASM keeps the runtime and the toolchain attack surface narrow. Anything richer — structs, lists, strings — is built on top of pointers + lengths, which the chain can audit byte-by-byte rather than trusting a typed serialization layer.
2. std vs no_std (Rust-specific)
A common confusion: the WASM-primitives restriction is separate from the question of whether the standard library is available. Different layers, different concerns.
2.1 The three Rust WASM targets
| Target triple | std available? | Why |
|---|---|---|
wasm32-unknown-unknown | No | No operating system to host std's syscalls. Pyde uses this target. |
wasm32-wasip1 | Yes | The WebAssembly System Interface provides an OS-shaped syscall ABI; std maps std::fs, std::time, std::net, etc. onto WASI imports. |
wasm32-unknown-emscripten | Yes | Emscripten provides a JavaScript-hosted faux-OS; std maps onto emscripten's runtime. |
Pyde's wasmtime configuration explicitly does not enable any WASI snapshot (// (No WASI imports allowed; not enabled at all.) — see Chapter 3 §3.2 of the book), so even a wasm32-wasip1-compiled binary's WASI imports would be rejected at deploy time by the import allowlist check.
2.2 Why Pyde uses wasm32-unknown-unknown
Three reasons, in descending order of importance:
-
Determinism.
std::time::SystemTime::now()returns the wall clock — a value that differs across the 128 validators executing the same transaction. Threading primitives (std::sync::Mutex,std::thread) introduce scheduling non-determinism. The chain would halt the moment two validators diverged onnow(). The import allowlist (HOST_FN_ABI_SPEC §3.1) enforces this by rejectingwasi:*imports at deploy time. -
Audit surface. A trivial
no_stdcontract compiles to ~5 KB of WASM. The same contract with std drags in ~150–250 KB of runtime initialization code. Every byte costs gas to deploy + adds attack surface to audit. -
Sandbox cleanliness. WASI's API surface (filesystem, network, environment, clocks) is exactly what a contract should not be able to touch. Even if individual imports were filtered, leaving the std-on-WASI scaffolding in place encourages authors to write code that would be portable to non-blockchain hosts — which is the wrong mental model for a contract.
2.3 What you actually have in a no_std contract
#![allow(unused)] fn main() { //! A canonical no_std contract module preamble. // (a) — Disable the standard library. This is REQUIRED for wasm32-unknown-unknown // since std is not built for that target. #![no_std] // (b) — You still get `core`, which is std minus the OS-dependent parts. // `core::convert`, `core::mem`, `core::option::Option`, `core::result::Result`, // `core::cmp`, slices, arrays, integers, floats, traits, generics — all here. use core::convert::TryFrom; use core::mem; // (c) — Optionally pull in `alloc` if you want heap-allocated types like // Vec, Box, String, BTreeMap. `alloc` is part of the Rust standard // distribution but is split out from `std` precisely so no_std // targets can use it without dragging in the OS scaffolding. // // Requires you to wire a GLOBAL ALLOCATOR (see (d)). extern crate alloc; use alloc::vec::Vec; use alloc::string::String; // (d) — Provide a global allocator. The chain doesn't care which one; // common choices for size-conscious contracts: // - `dlmalloc-rs` (~12 KB, full malloc/free semantics) // - `wee_alloc` (~1 KB, smallest, slowest) // - `talc` (~3 KB, modern dlmalloc alternative) // If you skip this, `alloc::*` types are compile errors. That's // fine for contracts that hold to static slot buffers and stack-allocated // calldata only. #[global_allocator] static ALLOCATOR: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; // (e) — Define a panic handler. wasm32-unknown-unknown has no default; // you MUST provide one or the linker rejects the build. // // For a contract, "panic" should be "trap and revert" — the chain's // per-tx overlay handles state rollback automatically. #[panic_handler] fn panic(_info: &core::panic::PanicInfo) -> ! { // Trap the WASM execution. The engine catches the trap and converts // it into a transaction revert with rollback of the per-tx overlay. core::arch::wasm32::unreachable() } }
2.4 What you do NOT have
#![allow(unused)] fn main() { // All of the following are COMPILE ERRORS in a wasm32-unknown-unknown contract: use std::fs::File; // no filesystem use std::time::SystemTime; // no clock use std::net::TcpStream; // no network use std::thread; // no threading use std::sync::Mutex; // would compile (it's a no_std-able // primitive in core/alloc) but // the std re-export is gone println!("..."); // no stdout eprintln!("..."); // no stderr }
You can implement contract-side logging by emitting an event via pyde::emit_event (see HOST_FN_ABI_SPEC §7.5), which writes to the transaction receipt log. That is the contract-author equivalent of println!.
3. Linear memory model
3.1 The 64 MB sandbox
Every contract instance has its own linear memory — a contiguous, byte-addressable region that starts at offset 0 and grows in 64 KB pages up to a hard cap of 64 MB (1024 pages, per Chapter 3 §3.5b).
Linear memory layout (conceptual):
offset 0 offset 64 MB (max)
┌──────────────────────────────────────────────────────────────┐
│ data segments │ stack │ heap (if allocator) │ free │
│ (constants) │ │ │ │
└──────────────────────────────────────────────────────────────┘
grows ↓ ↑ ↑ ↓
at compile stack pointer allocator grow on demand
time decreases on bump pointer
function call
- Data segments at the bottom hold compile-time constants (string literals, static
[u8; 32]arrays). Read-only by convention; the language compiler emits initialization instructions that the wasmtime engine runs once at instantiation. - Stack grows downward from a fixed offset chosen by the linker. Function locals + small fixed-size arrays live here.
- Heap is only present if your contract instantiates a global allocator. Vec / Box / String allocations carve from here.
3.2 WASM ⇄ host is NOT shared memory
This is the single most important mental model to internalize:
The host (engine) and the contract (WASM instance) live in separate address spaces. When the contract passes a "pointer" to a host function, it is passing a 32-bit offset into its own linear memory — a number, not a memory address the host can dereference.
When the host needs to read what the contract wrote, it goes through the wasmtime Memory API, which performs an explicit byte copy from the contract's linear memory into the host's regular Rust heap:
#![allow(unused)] fn main() { // Host-side: this is real memcpy, not a pointer dereference. let mut buf = [0u8; 32]; memory.read(&caller, offset as usize, &mut buf)?; }
When the host wants to write into the contract's linear memory (e.g., return data, sload result), it goes the other way:
#![allow(unused)] fn main() { // Host-side: also real memcpy. memory.write(&mut caller, offset as usize, &data)?; }
The implications:
- There is no zero-copy shared buffer between contract and host.
- Every byte that crosses the boundary in either direction is metered — the per-byte gas costs in the ABI table (e.g.,
+ 8 per byte of calldataforcross_call) are paying for these copies plus any host-side processing. - The contract's linear memory is opaque to the engine outside of explicit
memory.read/memory.writecalls. Host functions cannot inspect contract state by reaching into linear memory uninvited. - The sandbox is enforced:
memory.read/memory.writeperform a bounds check against the current memory size. Out-of-bounds access traps withMemoryOutOfBounds(HOST_FN_ABI_SPEC §3.4). Contracts cannot escape the sandbox via crafted offsets.
3.3 Memory ownership and lifetimes
A contract's linear memory is owned by the wasmtime Store that wraps the contract's instance. The Store lives for the duration of a single transaction (or sub-call within a transaction). At the end of that scope:
- Stack and heap are torn down (the Store is dropped).
- Anything the contract wrote to linear memory is gone — unless the contract explicitly called
sstoreto persist a byte to the chain's state.
So passing a pointer to a host function works only because the host synchronously copies the bytes out before the WASM call frame is destroyed. There is no facility for the host to retain a contract-side pointer across calls.
4. Pointer + length conventions
Pyde's host functions follow four pointer-shape conventions, summarized in HOST_FN_ABI_SPEC §3.2 and reproduced here with worked examples.
4.1 Fixed-size input (ptr: i32)
When a parameter has a fixed size known to both contract and host (e.g., a 32-byte address), only the pointer is passed. The length is implicit in the function's contract.
pyde::balance(account_ptr: i32, balance_out_ptr: i32) -> i32
──────────────── ─────
reads 32 bytes writes 16 bytes
from contract's into contract's
linear memory linear memory
Used for: 32-byte addresses, 32-byte hashes, 16-byte u128 values.
4.2 Variable-length input (ptr: i32, len: i32)
When a parameter has variable size (e.g., calldata, a function name, an event payload), both the pointer and the length are passed.
pyde::emit_event(
topics_ptr: i32, n_topics: i32, ; n_topics × 32 bytes
data_ptr: i32, data_len: i32 ; data_len bytes
) -> i32
4.3 Fixed-size output (out_ptr: i32)
When a host function returns fixed-size data (e.g., the 32 bytes of caller()), the caller pre-allocates the buffer and passes its offset. The host writes exactly the documented number of bytes.
pyde::caller(out_ptr: i32) -> i32 ; host writes 32 bytes at out_ptr
4.4 Variable-size output (out_ptr: i32, out_len_ptr: i32)
When the return data's size is not known in advance, the caller passes both:
- An output buffer (whatever size it deems sufficient).
- A pointer to an
i32location where the host writes the actual length used.
pyde::calldata_copy(
dst_ptr: i32, dst_capacity: i32, ; caller's buffer + its capacity
src_offset: i32, copy_len: i32 ; what to read from calldata
) -> i32 ; host returns actual bytes copied
If the host's data exceeds the caller's capacity, the spec defines the behavior per host fn (usually ERR_BUFFER_TOO_SMALL with the out_len_ptr set to the required size, so the caller can re-call with a larger buffer).
4.5 Byte order: always little-endian
All multi-byte integers crossing the boundary are little-endian, matching the WASM linear-memory native byte order (HOST_FN_ABI_SPEC §3.2). This applies to:
- The 16 bytes of a
u128value - The 8 bytes of a
u64block height or timestamp - The 4 bytes of an
i32length written viaout_len_ptr
Big-endian encoding would require the host to byte-swap on every read/write — wasted cycles for no portability benefit, since the only consumer is the wasmtime instance that already speaks little-endian.
4.6 Sizes summary
| Type | Bytes | Encoding |
|---|---|---|
| Address | 32 | Raw bytes (Poseidon2 output is canonical) |
| Slot hash | 32 | Raw bytes |
| Hash output (Blake3, Poseidon2, Keccak256) | 32 | Raw bytes |
u128 (balance, value, amount) | 16 | Little-endian |
u64 (block height, wave id, chain id, timestamp) | 8 | Little-endian |
u32 (gas, length, counter) | 4 | Little-endian |
5. Declaring host function imports (per language)
A "host function import" is the contract telling the WASM runtime: "I want to call a function named foo from module pyde; here is the signature I expect it to have." The toolchain emits a WASM (import "pyde" "foo" (func ...)) declaration. At instantiation time, wasmtime binds each import to the Rust function the host registered with the linker. If the contract declares an import the host doesn't recognize, instantiation fails and the deploy is rejected.
5.1 Rust
pyde-hostships every host fn declared in HOST_FN_ABI_SPEC §7 underpyde::raw::*, plus ergonomic wrappers underpyde::ctx::*/pyde::calldata::*/pyde::hash::*/pyde::call::*. Rust contracts addpyde-hostto theirCargo.toml, dropuse pyde_host as pyde;, and skip writing theextern "C"block below entirely. The walkthrough that follows is the under-the-hood shapepyde-hostemits — useful to understand even if you never write one by hand.
#![allow(unused)] fn main() { // Tell the Rust compiler that the FFI function `sload` is provided by the // WASM import module named "pyde". When the contract is compiled to WASM, // this becomes a `(import "pyde" "sload" (func ...))` declaration in the // resulting binary. #[link(wasm_import_module = "pyde")] // `extern "C"` selects the C ABI for the function. On wasm32-unknown-unknown, // the C ABI is essentially the WASM ABI — primitives go through directly, // pointers are i32, no name mangling. This is what we want for host fn imports. extern "C" { // `sload` reads a 32-byte slot from contract storage. // // slot_ptr — i32 offset in linear memory pointing to 32 bytes // representing the slot hash to read. // value_out_ptr — i32 offset in linear memory where the host writes // the 32-byte slot value on success. // // Returns: 0 on success; ERR_SLOT_NOT_FOUND if the slot is unset; // negative error codes for other failures (HOST_FN_ABI_SPEC §4). fn sload(slot_ptr: i32, value_out_ptr: i32) -> i32; // `sstore` writes a 32-byte slot. fn sstore(slot_ptr: i32, value_ptr: i32) -> i32; // `emit_event` appends an event log entry to the transaction receipt. fn emit_event( topics_ptr: i32, n_topics: i32, data_ptr: i32, data_len: i32, ) -> i32; } }
Notes:
- The
#[link(wasm_import_module = "pyde")]attribute is parsed byrustcand emitted into thelinkingcustom section of the resulting.wasm. The wasm-ld linker picks it up and produces the corresponding WASMimportdeclarations. - You can have multiple
extern "C"blocks, each with its own#[link]attribute, if you need to import from multiple modules. Pyde only usespydeas the module name, so one block is sufficient. unsafe fnis not required at the declaration site; calls to these functions DO requireunsafe { ... }because they take raw pointers and can violate memory safety if the offsets are wrong.
5.2 TinyGo
package contract
import "unsafe"
// The //go:wasmimport directive (Go 1.21+ / TinyGo 0.30+) tells the
// compiler to emit a WASM import declaration. The two arguments are
// the WASM module name and the WASM function name, in that order.
//
// Note: Go's calling convention is normally NOT compatible with the
// WASM ABI (Go uses its own register-spilling scheme). The
// //go:wasmimport directive switches the relevant function to the
// WASM ABI for ONLY that import declaration — the rest of the
// program keeps Go's normal calling convention.
//go:wasmimport pyde sload
func sload(slotPtr int32, valueOutPtr int32) int32
//go:wasmimport pyde sstore
func sstore(slotPtr int32, valuePtr int32) int32
//go:wasmimport pyde emit_event
func emitEvent(
topicsPtr int32, nTopics int32,
dataPtr int32, dataLen int32,
) int32
Notes:
- TinyGo's
wasmimportwas stabilized in TinyGo 0.30 (March 2024). Earlier versions used the experimental//go:wasm-moduledirective with separate//go:export-namelines. - The function body MUST be empty / absent —
wasmimportis a declaration, not a definition. The Go compiler will reject any body. - Go's
inttype is platform-dependent (32 or 64 bits); always use explicitint32/int64for WASM imports.
5.3 AssemblyScript
// The @external decorator tells the AssemblyScript compiler to emit a
// WASM import declaration. The two arguments are the WASM module name
// and the WASM function name.
//
// AssemblyScript's `usize` type is the linear-memory offset type:
// 32-bit on wasm32 (which is Pyde's target), so it's effectively a
// type alias for u32 / i32 at the binary level.
@external("pyde", "sload")
declare function sload(slot_ptr: usize, value_out_ptr: usize): i32;
@external("pyde", "sstore")
declare function sstore(slot_ptr: usize, value_ptr: usize): i32;
@external("pyde", "emit_event")
declare function emit_event(
topics_ptr: usize, n_topics: i32,
data_ptr: usize, data_len: i32,
): i32;
Notes:
- The
declarekeyword tells AS this is a declaration only — no body. - AssemblyScript also supports declaring imports via the
@external.jsdecorator (for hosted JS environments), but that's not applicable here. Always use plain@external. - AS does not have an
unsafekeyword; all linear-memory access is implicitly unsafe. Use thememory.fill,memory.copy,load<T>,store<T>builtins to interact with memory.
5.4 C (clang --target=wasm32)
// In C, host function imports are just `extern` declarations with an
// attribute selecting the WASM import module.
// The __attribute__((import_module(...))) and ((import_name(...)))
// pair tells clang's WASM backend to emit a WASM import declaration
// with the given module and name. Without these attributes, the
// linker would try to resolve the symbol locally and fail.
__attribute__((import_module("pyde"), import_name("sload")))
extern int32_t sload(int32_t slot_ptr, int32_t value_out_ptr);
__attribute__((import_module("pyde"), import_name("sstore")))
extern int32_t sstore(int32_t slot_ptr, int32_t value_ptr);
__attribute__((import_module("pyde"), import_name("emit_event")))
extern int32_t emit_event(
int32_t topics_ptr, int32_t n_topics,
int32_t data_ptr, int32_t data_len
);
Notes:
- C is the lowest-level option and gives you the most direct mapping to WASM. The
__attribute__syntax is the only way to declare a WASM import in C. - You'll need
-Wl,--no-entryat link time since contracts don't have amain. - The
wasm32-wasilibc (provided bywasi-libc) gives you the usual C library functions, but using it implicitly imports WASI functions that Pyde rejects. For Pyde contracts, link against a freestanding setup (no libc) or use a libc that compiles to no WASI imports (e.g., a custom staticmemcpyandmemset).
6. Staging data for host calls
The pattern is identical regardless of language:
- Place the bytes you want to pass in linear memory (stack array, static buffer, or heap allocation).
- Take the offset (the "pointer") of that memory location.
- Pass the offset to the host function.
The host then copies the bytes out using wasmtime::Memory::read.
6.1 Rust: stack-allocated buffers
#![allow(unused)] fn main() { // Read the balance of `account` (a 32-byte address) into the local `balance`. pub fn read_balance(account: &[u8; 32]) -> Result<u128, i32> { // Allocate a 16-byte output buffer on the function's STACK FRAME. // The compiler reserves 16 bytes in linear memory by adjusting the // stack pointer; the address of `balance_buf` is that reserved offset. let mut balance_buf = [0u8; 16]; // SAFETY: `account` is a valid 32-byte slice in linear memory because // it was passed in by a caller who satisfied the same invariant; // `balance_buf` is a live local on this stack frame. Both pointers // are valid for the duration of the host call. let rc = unsafe { // `account.as_ptr() as i32` reinterprets the 32-bit linear-memory // offset as a signed i32. On wasm32, a *const u8 is a 32-bit // value; the cast is a bit-pattern reinterpretation, not a // truncation. balance( account.as_ptr() as i32, balance_buf.as_mut_ptr() as i32, ) }; // Map the i32 status code to a Result. if rc != 0 { return Err(rc); } // Decode the 16 bytes the host wrote as a little-endian u128 // (matches HOST_FN_ABI_SPEC §3.2 byte-order rule). Ok(u128::from_le_bytes(balance_buf)) } }
6.2 Rust: static buffers (when you need stable offsets)
#![allow(unused)] fn main() { // A static buffer lives at a fixed linear-memory offset for the lifetime // of the contract instance. Useful when you need to pass the same buffer // across multiple host calls without restaging. // `static mut` is technically unsafe but is the standard pattern for // no_std contract scratch space. The wasmtime sandbox prevents any // real race condition since there is only one thread per instance. static mut SCRATCH_32: [u8; 32] = [0u8; 32]; pub fn read_self_address() -> [u8; 32] { // SAFETY: single-threaded WASM, exclusive access to SCRATCH_32 within // this function call. The host call below is the only writer. unsafe { // Call the host fn that writes our own contract's 32-byte address // into the scratch buffer. self_address(SCRATCH_32.as_mut_ptr() as i32); // Return a copy of the populated buffer. The copy is necessary // because `static mut` cannot be safely returned by reference. SCRATCH_32 } } }
6.3 Rust: heap-allocated buffers (when you need variable-length)
#![allow(unused)] fn main() { // If your contract has a global allocator (see §2.3), you can use // `Vec<u8>` for variable-length staging. extern crate alloc; use alloc::vec::Vec; pub fn emit_typed_event(topic_zero: &[u8; 32], payload: &[u8]) { // The topics array for emit_event is contiguous 32-byte entries. // We have one topic, so it's 32 bytes. let topics = topic_zero; // already in linear memory; no copy needed // The payload is variable-length and might have been built up by // concatenating fields. If `payload` is already a contiguous slice // we don't need to allocate at all. // // SAFETY: topics is &[u8; 32] (32 bytes); payload is a contiguous // &[u8] in linear memory. Both stay alive for the duration of the // host call. unsafe { emit_event( topics.as_ptr() as i32, // topics_ptr 1, // n_topics (just topic-0 here) payload.as_ptr() as i32, // data_ptr payload.len() as i32, // data_len ); } } }
6.4 TinyGo: same pattern, different syntax
// Stack-allocated buffer in TinyGo.
func readBalance(account *[32]byte) (lo uint64, hi uint64, err int32) {
// Local array: lives in the function's stack frame.
var balanceBuf [16]byte
// Convert &balanceBuf[0] to int32 via unsafe.Pointer + uintptr.
// TinyGo's WASM target makes pointers 32 bits, so int32 fits.
rc := balance(
int32(uintptr(unsafe.Pointer(&account[0]))),
int32(uintptr(unsafe.Pointer(&balanceBuf[0]))),
)
if rc != 0 {
return 0, 0, rc
}
// Decode the 16-byte LE u128 into two uint64 halves.
lo = binary.LittleEndian.Uint64(balanceBuf[0:8])
hi = binary.LittleEndian.Uint64(balanceBuf[8:16])
return lo, hi, 0
}
6.5 AssemblyScript: allocate explicitly
// AssemblyScript exposes the runtime allocator via `memory.allocate`,
// which returns a `usize` (linear-memory offset). For contracts, this
// is the standard way to stage buffers.
export function readBalance(accountPtr: usize): u64 {
// Allocate a 16-byte buffer in linear memory and zero it.
const balanceBuf = changetype<usize>(memory.allocate(16));
memory.fill(balanceBuf, 0, 16);
// Call the host fn. AssemblyScript's usize is identical at the
// binary level to i32 on wasm32, so no cast is needed.
const rc = balance(accountPtr, balanceBuf);
if (rc != 0) {
// Convention: encode the error code in the high bits of the
// return, or use a separate error-out mechanism.
return u64.MAX_VALUE;
}
// Read the lower 8 bytes as a u64 (assumes balance fits in 64 bits;
// for full u128 you'd return two u64 halves like Go).
return load<u64>(balanceBuf);
}
7. Storage — variable-length values + slot derivation
Pyde v1 storage is variable-length per HOST_FN_ABI_SPEC §7.1. Three host fns:
pyde::sload(slot_ptr, out_ptr, out_max_len) -> i32 // actual_len, or -1 (SLOAD_MISSING)
pyde::sstore(slot_ptr, val_ptr, val_len) // val_len capped at 16 KB
pyde::sdelete(slot_ptr) // tombstone the slot
Slot keys are always 32 bytes. Slot values are whatever the contract writes — u64::to_be_bytes() (8 bytes), u128::to_be_bytes() (16 bytes), an address (32 bytes), arbitrary bytes up to 16 KB. No 32-byte padding required.
Contracts derive their own slot keys via the canonical recipe:
slot = Poseidon2(self_address || field_bytes [|| key_bytes])
Wrap the derivation in a single derive_slot(field, key) helper. Every read/write becomes a one-line call.
7.1 The 5-line derive_slot helper (Rust)
#![allow(unused)] fn main() { #[link(wasm_import_module = "pyde")] extern "C" { fn sload(slot_ptr: *const u8, out_ptr: *mut u8, out_max_len: i32) -> i32; fn sstore(slot_ptr: *const u8, val_ptr: *const u8, val_len: i32); fn self_address(addr_out_ptr: *mut u8) -> i32; fn hash_poseidon2(in_ptr: *const u8, in_len: i32, out_ptr: *mut u8); } /// `slot = Poseidon2(self_address || field || key)`. Pass `key = &[]` /// for scalar slots. Fixed-size buffer caps the preimage at 32 + 96 = /// 128 bytes (covers any realistic field name + composite key). fn derive_slot(field: &[u8], key: &[u8]) -> [u8; 32] { let mut preimage = [0u8; 32 + 96]; let total = 32 + field.len() + key.len(); unsafe { self_address(preimage.as_mut_ptr()); } preimage[32..32 + field.len()].copy_from_slice(field); preimage[32 + field.len()..total].copy_from_slice(key); let mut out = [0u8; 32]; unsafe { hash_poseidon2(preimage.as_ptr(), total as i32, out.as_mut_ptr()); } out } }
7.2 Rust — scalar + mapping + composite-key
#![allow(unused)] fn main() { const FIELD_TOTAL_SUPPLY: &[u8] = b"total_supply"; const FIELD_BALANCES: &[u8] = b"balances"; const FIELD_ALLOWANCES: &[u8] = b"allowances"; /// Read/write a u128 as 16 raw bytes (no 32-byte padding). fn read_u128(field: &[u8], key: &[u8]) -> u128 { let slot = derive_slot(field, key); let mut buf = [0u8; 16]; // -1 (missing) and 0 (empty) both default to 0 here. let actual = unsafe { sload(slot.as_ptr(), buf.as_mut_ptr(), 16) }; if actual <= 0 { return 0; } u128::from_be_bytes(buf) } fn write_u128(field: &[u8], key: &[u8], value: u128) { let slot = derive_slot(field, key); let bytes = value.to_be_bytes(); // exactly 16 bytes unsafe { sstore(slot.as_ptr(), bytes.as_ptr(), 16); } } // Usage — uniform across scalar / mapping / composite-key: let supply = read_u128(FIELD_TOTAL_SUPPLY, &[]); // scalar let balance = read_u128(FIELD_BALANCES, &owner); // mapping (key = 32-byte addr) // Composite key — pack inline: let mut k = [0u8; 64]; k[..32].copy_from_slice(&owner); k[32..].copy_from_slice(&spender); let allowed = read_u128(FIELD_ALLOWANCES, &k); // nested mapping }
read_u64 / write_u64 follow the same shape with 8-byte buffers; read_address / write_address use 32. Storage costs (5000 base + 32/byte on sstore) scale with what you write — pay for what you use, no 32-byte padding overhead.
7.3 TinyGo — same shape, //go:wasmimport
//go:wasmimport pyde sload
func sload(slotPtr int32, outPtr int32, outMaxLen int32) int32
//go:wasmimport pyde sstore
func sstore(slotPtr int32, valPtr int32, valLen int32)
//go:wasmimport pyde self_address
func self_address(addrOutPtr int32) int32
//go:wasmimport pyde hash_poseidon2
func hash_poseidon2(inPtr int32, inLen int32, outPtr int32)
func deriveSlot(field []byte, key []byte) [32]byte {
var preimage [32 + 96]byte
total := 32 + len(field) + len(key)
self_address(int32(uintptr(unsafe.Pointer(&preimage[0]))))
copy(preimage[32:32+len(field)], field)
copy(preimage[32+len(field):total], key)
var out [32]byte
hash_poseidon2(
int32(uintptr(unsafe.Pointer(&preimage[0]))),
int32(total),
int32(uintptr(unsafe.Pointer(&out[0]))),
)
return out
}
var fieldBalances = []byte("balances")
func readBalance(owner [32]byte) uint64 {
slot := deriveSlot(fieldBalances, owner[:])
var buf [8]byte
actual := sload(
int32(uintptr(unsafe.Pointer(&slot[0]))),
int32(uintptr(unsafe.Pointer(&buf[0]))),
8,
)
if actual <= 0 { return 0 }
return binary.BigEndian.Uint64(buf[:])
}
7.4 AssemblyScript — same shape, @external
@external("pyde", "sload")
declare function sload(slot_ptr: usize, out_ptr: usize, out_max_len: i32): i32;
@external("pyde", "sstore")
declare function sstore(slot_ptr: usize, val_ptr: usize, val_len: i32): void;
@external("pyde", "self_address")
declare function self_address(addr_out_ptr: usize): i32;
@external("pyde", "hash_poseidon2")
declare function hash_poseidon2(in_ptr: usize, in_len: i32, out_ptr: usize): void;
function deriveSlot(field: StaticArray<u8>, key: StaticArray<u8> | null): StaticArray<u8> {
const fieldLen = field.length;
const keyLen = key != null ? key.length : 0;
const total = 32 + fieldLen + keyLen;
const preimage = new StaticArray<u8>(total);
self_address(changetype<usize>(preimage));
for (let i = 0; i < fieldLen; i++) preimage[32 + i] = field[i];
if (key != null) {
for (let i = 0; i < keyLen; i++) preimage[32 + fieldLen + i] = key[i];
}
const out = new StaticArray<u8>(32);
hash_poseidon2(changetype<usize>(preimage), total, changetype<usize>(out));
return out;
}
7.5 C — same shape, import_module
__attribute__((import_module("pyde"), import_name("sload")))
extern int32_t sload(const uint8_t* slot_ptr, uint8_t* out_ptr, int32_t out_max_len);
__attribute__((import_module("pyde"), import_name("sstore")))
extern void sstore(const uint8_t* slot_ptr, const uint8_t* val_ptr, int32_t val_len);
__attribute__((import_module("pyde"), import_name("self_address")))
extern int32_t self_address(uint8_t* addr_out_ptr);
__attribute__((import_module("pyde"), import_name("hash_poseidon2")))
extern void hash_poseidon2(const uint8_t* in_ptr, int32_t in_len, uint8_t* out_ptr);
static void derive_slot(const uint8_t* field, int32_t field_len,
const uint8_t* key, int32_t key_len,
uint8_t out[32]) {
uint8_t preimage[128];
self_address(preimage);
for (int32_t i = 0; i < field_len; i++) preimage[32 + i] = field[i];
for (int32_t i = 0; i < key_len; i++) preimage[32 + field_len + i] = key[i];
hash_poseidon2(preimage, 32 + field_len + key_len, out);
}
7.6 Pre-migration: *_by_field is gone
An earlier ABI revision shipped host-side convenience variants — sload_by_field / sstore_by_field / sdelete_by_field — that did the slot derivation inside the host. These were dropped in the variable-length storage migration to keep the host fn surface minimal and uniform with the engine's executor. The 5-line derive_slot helper above recovers the ergonomics without adding host fns; gas is comparable (a hash_poseidon2 call replaces what was previously folded into the host base cost).
If you're updating an older contract, replace every sX_by_field(field, field_len, key, key_len, ...) call with:
#![allow(unused)] fn main() { let slot = derive_slot(field, key); sX(slot.as_ptr(), ...); // sload / sstore / sdelete with the new variable-length signatures }
Mixing forms in the same contract is fine — they read/write the same JMT.
The erc20-token example exercises all three storage layouts in one contract: scalar total_supply, mapping balances[owner], and composite-key mapping allowances[owner][spender]. The same read_u128(field, key) helper handles all three by passing different key byte slices.
8. Cross-contract call patterns
This section walks through the most complex per-language pattern: calling another contract via pyde::cross_call. The mechanics generalize to every other variable-data host function (emit_event, calldata_copy, parachain_storage_write, etc.).
8.1 The host function signature (recap)
From HOST_FN_ABI_SPEC §7.8:
pyde::cross_call(
target_ptr: i32, ; → 32 bytes (target address)
fn_name_ptr: i32, fn_name_len: i32, ; → UTF-8 function name
calldata_ptr: i32, calldata_len: i32, ; → encoded args
value_ptr: i32, ; → 16 bytes (u128 PYDE value)
gas_limit: i64, ; sub-call gas budget
return_data_out_ptr: i32, ; ← caller's output buffer
return_data_out_len_ptr: i32 ; ← caller's i32 length slot
) -> i32 ; status code
8.2 Rust — calling token.transfer(recipient, amount)
#![allow(unused)] fn main() { // Import the host fn (see §5.1). #[link(wasm_import_module = "pyde")] extern "C" { fn cross_call( target_ptr: i32, fn_name_ptr: i32, fn_name_len: i32, calldata_ptr: i32, calldata_len: i32, value_ptr: i32, gas_limit: i64, return_data_out_ptr: i32, return_data_out_len_ptr: i32, ) -> i32; } // Invoke `transfer(recipient, amount)` on the contract at `token_addr`. // // Parameters: // token_addr — 32-byte address of the token contract to call into. // recipient — 32-byte address that should receive the tokens. // amount — quantity to transfer, as a u128 (16 bytes LE on wire). // // Returns: // Ok(()) — sub-call succeeded; tokens transferred. // Err(rc) — sub-call failed with the engine's error code. pub fn transfer_via_token( token_addr: &[u8; 32], recipient: &[u8; 32], amount: u128, ) -> Result<(), i32> { // ── 1. Encode the calldata ──────────────────────────────────── // // The target's `transfer(address, uint128)` expects its inputs // serialized as: // bytes 0..32 — recipient address (raw 32 bytes) // bytes 32..48 — amount as little-endian u128 (16 bytes) // // Total calldata length: 48 bytes. We stage it in a stack-frame // array since the size is fixed and small. let mut calldata = [0u8; 48]; calldata[..32].copy_from_slice(recipient); // recipient slot calldata[32..48].copy_from_slice(&amount.to_le_bytes());// amount slot // ── 2. Stage the constants ───────────────────────────────────── // // `fn_name` is a byte literal; literals live in the contract's // data segment at a fixed offset. `as_ptr()` returns that offset. let fn_name: &[u8] = b"transfer"; // No PYDE value attached. cross_call requires a 16-byte u128 even // when zero, so we stage a zeroed buffer. let zero_value = [0u8; 16]; // ── 3. Reserve a return-data buffer + length slot ────────────── // // `transfer` returns no data in most ERC20-style contracts, but // we provision a small buffer anyway in case the target returns // a status code. Sized at 32 bytes (one word). let mut return_buf = [0u8; 32]; let mut return_len: i32 = 0; // ── 4. Issue the host call ───────────────────────────────────── // // SAFETY: every pointer below references a live local on this // stack frame. The host copies the bytes out synchronously, so // the locals only need to remain valid through the duration of // the call. After the call returns, we no longer need them. let rc = unsafe { cross_call( // target_ptr → contract to call token_addr.as_ptr() as i32, // fn_name_ptr → "transfer" // fn_name_len → 8 (length of "transfer") fn_name.as_ptr() as i32, fn_name.len() as i32, // calldata_ptr → start of the 48-byte encoded args // calldata_len → 48 calldata.as_ptr() as i32, calldata.len() as i32, // value_ptr → 16-byte zero buffer (no value attached) zero_value.as_ptr() as i32, // gas_limit → 100,000 gas budget for the sub-call. // The engine deducts this from our remaining // gas; the sub-call's actual usage is // refunded above its own consumption. 100_000, // return_data_out_ptr → where the host writes return data // return_data_out_len_ptr → where the host writes the actual // number of bytes it returned return_buf.as_mut_ptr() as i32, (&mut return_len) as *mut i32 as i32, ) }; // ── 5. Translate status code into Rust Result ────────────────── // // 0 = success. Anything else is an error code documented in // HOST_FN_ABI_SPEC §4. We propagate it up as-is so the caller // can decide whether to retry, revert, etc. if rc == 0 { // `return_buf[..return_len as usize]` is the return data slice // if the caller wants to inspect it. transfer() conventionally // returns nothing, so we ignore it here. Ok(()) } else { Err(rc) } } }
8.3 TinyGo — same pattern
package contract
import (
"encoding/binary"
"unsafe"
)
//go:wasmimport pyde cross_call
func crossCall(
targetPtr int32,
fnNamePtr int32, fnNameLen int32,
calldataPtr int32, calldataLen int32,
valuePtr int32,
gasLimit int64,
returnDataOutPtr int32,
returnDataOutLenPtr int32,
) int32
// transferViaToken invokes the named token's transfer(address, uint128)
// function on behalf of this contract.
//
// Note: Go has no native u128, so the amount is split into two uint64s
// (low + high) and reassembled into 16 little-endian bytes before the call.
func transferViaToken(
tokenAddr *[32]byte,
recipient *[32]byte,
amountLo uint64,
amountHi uint64,
) int32 {
// ── 1. Encode calldata = recipient (32) + amount (16 LE) = 48 bytes ─
var calldata [48]byte
copy(calldata[:32], recipient[:])
binary.LittleEndian.PutUint64(calldata[32:40], amountLo)
binary.LittleEndian.PutUint64(calldata[40:48], amountHi)
// ── 2. Stage constants ────────────────────────────────────────────
fnName := []byte("transfer") // backing array lives on heap
var zeroValue [16]byte // stack-allocated; auto-zeroed
// ── 3. Reserve return buffer + length slot ────────────────────────
var returnBuf [32]byte
var returnLen int32 = 0
// ── 4. Issue the host call ────────────────────────────────────────
//
// unsafe.Pointer + uintptr is Go's standard way to obtain a raw
// address. Cast to int32 because wasm32 pointers are 32 bits.
return crossCall(
int32(uintptr(unsafe.Pointer(&tokenAddr[0]))), // target_ptr
int32(uintptr(unsafe.Pointer(&fnName[0]))), // fn_name_ptr
int32(len(fnName)), // fn_name_len
int32(uintptr(unsafe.Pointer(&calldata[0]))), // calldata_ptr
int32(len(calldata)), // calldata_len
int32(uintptr(unsafe.Pointer(&zeroValue[0]))), // value_ptr
100_000, // gas_limit
int32(uintptr(unsafe.Pointer(&returnBuf[0]))), // return_data_out_ptr
int32(uintptr(unsafe.Pointer(&returnLen))), // return_data_out_len_ptr
)
}
8.4 AssemblyScript — same pattern
// Host fn declaration.
@external("pyde", "cross_call")
declare function cross_call(
target_ptr: usize,
fn_name_ptr: usize, fn_name_len: i32,
calldata_ptr: usize, calldata_len: i32,
value_ptr: usize,
gas_limit: i64,
return_data_out_ptr: usize,
return_data_out_len_ptr: usize,
): i32;
// transferViaToken invokes transfer(address, uint128) on a target token.
//
// Parameters take usize (linear-memory offsets) because AssemblyScript
// has no native fixed-array-by-value convention — the caller stages the
// bytes themselves and passes the offsets in.
export function transferViaToken(
tokenAddrPtr: usize, // ← caller has staged 32 bytes here
recipientPtr: usize, // ← ...and 32 bytes here
amount_lo: u64,
amount_hi: u64,
): i32 {
// ── 1. Allocate calldata buffer (48 bytes) + copy recipient + amount ──
const calldata = changetype<usize>(__alloc(48));
memory.copy(calldata, recipientPtr, 32);
store<u64>(calldata + 32, amount_lo);
store<u64>(calldata + 40, amount_hi);
// ── 2. Stage fn_name as a UTF-8 byte buffer ───────────────────────────
const fnName = String.UTF8.encode("transfer", false);
const fnNamePtr = changetype<usize>(fnName);
const fnNameLen = fnName.byteLength;
// ── 3. Zero-value buffer ──────────────────────────────────────────────
const zeroValue = changetype<usize>(__alloc(16));
memory.fill(zeroValue, 0, 16);
// ── 4. Return buffer + length slot ────────────────────────────────────
const returnBuf = changetype<usize>(__alloc(32));
const returnLenPtr = changetype<usize>(__alloc(4));
store<i32>(returnLenPtr, 0);
// ── 5. Issue the host call ────────────────────────────────────────────
return cross_call(
tokenAddrPtr,
fnNamePtr, fnNameLen,
calldata, 48,
zeroValue,
100_000,
returnBuf,
returnLenPtr,
);
}
8.5 The four cross_call invariants — pattern + example
When a primary contract calls pyde::cross_call(target, fn_name, calldata, value, ...), four properties hold per HOST_FN_ABI_SPEC §7.8 — properties that distinguish cross_call from a regular function call within the same contract:
- Target's storage context. Sub-call sstores land in the TARGET's slot namespace (
Poseidon2(target_address ‖ field ‖ key)), not the caller's. Storage isolation is implicit because slot hashes include each contract'sself_address. caller()shift. Inside the callee,caller()returns the immediate caller-contract's address — the contract that issued thecross_call, NOT the tx originator (origin). Useful for the callee to authorise the call source; common pitfall to confuse it withorigin().- Value transfer. The
valueparameter debits the caller's native-PYDE balance and credits the target's. Inside the callee,tx_value()returns the samevalue. The transfer happens in the parent's frame, so even if the sub-call reverts (and the runner snapshots state), the transfer rolls back too. - Revert rollback. Sub-call trap (revert / unreachable / out-of-fuel / etc.) does NOT propagate to the parent. Instead the host fn returns
ERR_CROSS_CALL_FAILED = -10and rolls back all of the sub-call's storage / balance / event mutations. The parent observes the rc and decides whether to handle the failure or revert further.
The four invariants land cleanly in any caller / callee pair where the caller drives a cross_call into the callee — erc20-token (transfer paths) and upgradeable-proxy (delegate_call) ship as the canonical reference templates that exercise the storage-namespace + caller-shift + value-transfer + revert-rollback rules end-to-end. The proxy's forward(fn, calldata) dispatcher is the readable cross-contract harness; the ERC-20 transfer is the readable payable + state-mutation harness. Read them side-by-side as a calibration point for any cross-contract design.
Sub-call dispatch convention
The mock runner invokes the target's named export with the canonical (calldata_ptr: i32, calldata_len: i32) -> i32 shape — calldata_ptr is an offset into the callee's linear memory (the mock copies bytes from caller's to callee's at the boundary), calldata_len is the byte count, and the return value is the rc. Production engine dispatch goes through the contract's ABI metadata instead; the calldata-driven shape is a v1 runner convenience that keeps every example uniform.
9. FALCON-512 verification pattern
pyde::falcon_verify lets a contract check post-quantum signatures inside its own execution — the building block for multisig wallets, gasless / meta-transaction relayers, ZK-coupled off-chain authorizations, and anything else that needs in-contract sig checks against a known FALCON-512 public key.
9.1 Host function signature (recap)
#![allow(unused)] fn main() { #[link(wasm_import_module = "pyde")] extern "C" { /// Verify a FALCON-512 signature. /// /// `pk_ptr` must point to exactly 897 readable bytes (the /// `FalconPublicKey::SIZE` constant). `msg` and `sig` are /// variable-length. /// /// Returns 0 on valid, ERR_SIGNATURE_INVALID = -17 otherwise. /// Malformed pubkey or signature bytes are rejected as invalid /// rather than trapping — the contract can recover gracefully. pub fn falcon_verify( pk_ptr: *const u8, msg_ptr: *const u8, msg_len: i32, sig_ptr: *const u8, sig_len: i32, ) -> i32; } }
Per HOST_FN_ABI_SPEC §7.7. Gas: 50,000 base — verification is intentionally expensive because FALCON's algebra is heavy; design contracts so authors can amortize multiple sigs in one tx rather than one-sig-per-tx.
9.2 Storing FALCON pubkeys on-chain
A FALCON-512 pubkey is 897 bytes. Storing the full pubkey per-signer is wasteful (≈ 28 storage slots, each at 5,000 gas to write). The canonical optimization:
#![allow(unused)] fn main() { // Store the 32-byte Poseidon2 hash of the pubkey as the "signer ID". // Callers provide the full pubkey at verify time; the contract // recomputes the hash and matches against its registered set. const FIELD_SIGNERS: &[u8] = b"signers"; fn register_signer(slot_idx: u8, pubkey: &[u8]) { let mut hash = [0u8; 32]; unsafe { host_fns::hash_poseidon2(pubkey.as_ptr(), pubkey.len() as i32, hash.as_mut_ptr()); } // derive_slot is the §7.1 helper. let slot = derive_slot(FIELD_SIGNERS, &[slot_idx]); unsafe { host_fns::sstore(slot.as_ptr(), hash.as_ptr(), 32); } } }
One slot per signer instead of 29. The test framework's @pubkey_hash:NAME DSL prefix (see OTIGEN_TEST_SPEC §5.5) computes the identical hash at plan time so test init calls register the same IDs.
9.3 The verify-and-count loop
For a multi-signer check (threshold M-of-N), three contract-side checks bracket every falcon_verify call: pubkey-is-known, pubkey-not-already-counted, sig-actually-verifies. Skipping any of them leaks signature-forgery surface; doing them in the wrong order (e.g. verify before checking the pubkey is registered) wastes gas on attacker-supplied sigs that would never have counted anyway.
#![allow(unused)] fn main() { fn verify_signer_set( msg_ptr: *const u8, msg_len: i32, pubkeys: &[(*const u8, i32)], // each (ptr, len). len==0 ⇒ unused slot. sigs: &[(*const u8, i32)], threshold: u8, ) -> u8 { let mut seen: u8 = 0; // bitmap of signer-indices already counted let mut valid: u8 = 0; for ((pk_ptr, pk_len), (sig_ptr, sig_len)) in pubkeys.iter().zip(sigs) { if *pk_len == 0 { continue; } // 1. Identify which registered signer this pubkey is. let mut pk_hash = [0u8; 32]; unsafe { host_fns::hash_poseidon2(*pk_ptr, *pk_len, pk_hash.as_mut_ptr()); } let Some(idx) = lookup_signer_idx(&pk_hash) else { fail(b"UnknownSigner"); }; // 2. Anti-double-count. let bit = 1u8 << idx; if seen & bit != 0 { fail(b"DuplicateSigner"); } seen |= bit; // 3. FALCON-verify. let rc = unsafe { host_fns::falcon_verify(*pk_ptr, msg_ptr, msg_len, *sig_ptr, *sig_len) }; if rc != 0 { fail(b"BadSignature"); } valid += 1; } if valid < threshold { fail(b"InsufficientApprovals"); } valid } }
9.4 Canonical message construction
A FALCON sig binds a public key to a specific message. If the contract and the off-chain wallet disagree about what bytes go into that message, every verify fails. Two well-trodden conventions:
Action hash (Safe-style): the off-chain wallet pre-computes a 32-byte digest covering the full intent (Poseidon2(self_address ‖ target ‖ amount ‖ nonce ‖ chain_id)) and feeds that to each signer. The contract receives the hash as a bytes32 arg, verifies sigs against it, then uses the hash itself as the anti-replay key. Used by simple-multisig.
Structured message: the contract receives the structured fields (target, amount, etc.) and re-derives the canonical message at verify time. Cheaper for the wallet UI (no upfront hash computation), more gas inside the contract. Pick this when wallet ergonomics dominate.
9.5 Testing FALCON contracts
otigen test mocks falcon_verify with pyde_crypto::falcon::falcon_verify — the same primitive the engine uses. Combined with the [accounts] keypair declaration (OTIGEN_TEST_SPEC §4.1) and the @sig:NAME:args.IDX DSL (§5.5), authors write multisig tests without ever hand-pasting kilobyte FALCON blobs:
[accounts]
alice = { keypair = "falcon512" }
bob = { keypair = "falcon512" }
[[tests.calls]]
function = "execute"
args = [
"recipient", "500",
"0x4141414141414141414141414141414141414141414141414141414141414141", # action_hash
"@pubkey:alice", "@sig:alice:args.2",
"@pubkey:bob", "@sig:bob:args.2",
"0x", "0x",
]
The full live example — including replay protection, duplicate-signer rejection, and malformed-sig handling — is in otigen/examples/simple-multisig/, on the #[pyde::entry] macro substrate with 9 passing behaviour tests.
10. Upgradeable proxy pattern
The canonical upgradeable contract ships as two roles: a tiny proxy that owns the storage and an admin-controlled implementation pointer, plus one or more implementation contracts that hold the actual logic. Users always call the proxy; the proxy delegate_calls the current implementation to run that code against the proxy's own storage slots. Upgrading is a single sstore — point the slot at a new contract address.
10.1 Why delegate_call (vs cross_call)
delegate_call and cross_call are siblings under HOST_FN_ABI_SPEC §7.8 but enforce opposite storage semantics:
| Code from | Storage context | caller() value | Use for | |
|---|---|---|---|---|
cross_call | target | target's slots | the calling contract | Inter-contract APIs ("call into the token contract") |
delegate_call | target | caller's slots | preserved (whoever called the proxy) | Proxies, libraries that mutate caller's state, hot-swappable logic |
When the proxy delegate_calls into the impl, the impl's sstores land on the proxy's slots. The proxy "borrows" the impl's code; the impl never touches its own storage when called this way.
10.2 Slot layout
The proxy owns two reserved slots plus whatever the impl logic uses:
| Slot | Type | Purpose |
|---|---|---|
admin | address | Who can call upgrade(). Set once at init. |
impl | address | Current implementation pointer. Mutated only by upgrade(). |
| ...impl slots... | various | Whatever fields the impl writes via delegate_call (value, balances, total_supply, etc.). |
Storage-layout compatibility is a hard contract between the proxy and every impl. An impl that reads/writes slot X for purpose A is fundamentally incompatible with one that uses X for purpose B — the upgrade silently corrupts state. Two mitigations:
- Field-keyed storage (§7). Slots are derived from
Poseidon2(self_address ‖ field ‖ key). Two impls using the same field-name strings for the same data type collide cleanly; mismatched naming surfaces as obvious "fresh slot" reads. - Append-only impl evolution. New impls may add fields (new names) but must never repurpose an existing name. Document the slot-name vocabulary explicitly.
10.3 The proxy entry points (Rust, macro substrate)
otigen.toml declares the proxy's two reserved slots; the macro substrate generates typed accessors for both:
[state]
schema = [
{ name = "admin", type = "address" },
{ name = "logic", type = "address" },
]
[functions.init]
attributes = ["entry", "constructor"]
inputs = ["address"]
[functions.upgrade_to]
attributes = ["entry"]
inputs = ["address"]
[functions.forward]
attributes = ["entry"]
inputs = ["string", "bytes"]
outputs = ["bytes"]
The contract:
#![allow(unused)] #![no_std] fn main() { extern crate alloc; use alloc::string::String; use alloc::vec::Vec; use pyde_host as pyde; use pyde_host::call::CallError; use pyde_host::Address; pyde::declare_storage!(); pyde::declare_events!(); /// One-shot constructor. The deployer is recorded as admin. #[pyde::entry] fn init(initial_logic: Address) { storage::admin().write(pyde::ctx::caller()); storage::logic().write(initial_logic); } /// Admin-only logic-pointer swap. The proxy's storage is untouched /// — that's the whole point of the pattern. #[pyde::entry] fn upgrade_to(new_logic: Address) { if pyde::ctx::caller() != storage::admin().read() { pyde::revert("proxy: caller is not admin"); } let old_logic = storage::logic().read(); storage::logic().write(new_logic); events::Upgraded { old_logic, new_logic }.emit(); } /// Dispatcher. Delegate-calls `logic.function(calldata)` and hands /// the bytes back verbatim. The proxy can't borsh-decode the logic's /// return into a typed `T` because different logic functions return /// different shapes — so it uses `execute_delegate_raw` instead of /// the typed `execute_delegate<T>` wrapper. #[pyde::entry] fn forward(function: String, calldata: Vec<u8>) -> Vec<u8> { let logic = storage::logic().read(); match pyde::call::execute_delegate_raw(&logic, &function, &calldata) { Ok(bytes) => bytes, Err(CallError::Reverted(payload)) => { // Pass the logic's revert string straight through so the // caller sees exactly what the logic said. let msg = core::str::from_utf8(&payload) .unwrap_or("proxy: delegate-call failed"); pyde::revert(msg); } Err(CallError::InvalidFunction) => { pyde::revert("proxy: logic has no such function"); } Err(_) => pyde::revert("proxy: delegate-call failed"), } } }
10.4 The logic side
Logic contracts look like any other contract — #[pyde::entry] functions with typed args, storage::* accessors, the works. They don't have to know they'll be delegate-called. Their sstore writes land on the proxy's slots automatically because pyde::ctx::self_address() resolves to the proxy under delegate semantics:
#![allow(unused)] #![no_std] fn main() { extern crate alloc; use pyde_host as pyde; pyde::declare_storage!(); // [state] declares `value: u64` #[pyde::entry] fn set_value(v: u64) { storage::value().write(v); // writes to the PROXY's `value` slot } #[pyde::entry] fn get_value() -> u64 { storage::value().read() // reads from the PROXY's `value` slot } }
The proxy's forward("set_value", borsh::to_vec(&42_u64)) ends up writing 42 to the proxy's value slot. Upgrading logic to a new contract that uses the same value slot preserves the value across the upgrade.
10.5 Typed vs raw delegate-call: when to use which
The substrate exposes two wrappers — pick by whether the call site knows the return shape at compile time:
| Wrapper | Return shape | When |
|---|---|---|
pyde::call::execute_delegate::<T> | Result<T: BorshDeserialize, CallError> | The call site knows the return type. E.g. a proxy method that always calls one specific logic function: let v: u64 = pyde::call::execute_delegate(&logic, "get_value", &[])?;. |
pyde::call::execute_delegate_raw | Result<Vec<u8>, CallError> | The call site is a type-erased forwarder. E.g. the proxy's forward(function, calldata) -> Vec<u8> dispatcher above — it doesn't know what function returns and must hand the bytes back to its own caller verbatim. |
Both share the same CallError taxonomy and the same buffer / status / revert-payload handling; only the final borsh-decode differs.
10.6 Auth pitfalls
caller()semantics across delegate_call: the logic'spyde::ctx::caller()returns whoever called the proxy, not the proxy itself. If logic code gates a function on a specific caller, that gate triggers against the user — usually not what proxies want. Workaround: gate at the proxy layer (upgrade_tois admin-only above), and treat the logic as pure behaviour.- Re-entrancy under upgrade: if the logic is mid-execution when
upgrade_to()swaps the pointer, the still-running frame reads the old code (delegate_callis per-invocation, not a permanent binding). New top-level calls run the new logic. Practical implication: don't make the upgrade behaviour depend on state half-touched by the old logic. - Storage-layout compatibility: two logic versions using the same field-name string for the same data type collide cleanly under
Poseidon2(self_address ‖ field [‖ key])derivation (the chain's typed-storage slot hash, see HOST_FN_ABI_SPEC §7.1); mismatched naming silently corrupts state. New logic versions may add fields (new names) but must never repurpose an existing name. Document the field-name vocabulary explicitly. init()re-execution: theconstructorattribute prevents post-deploy calls on chain. In tests, each test starts from fresh state so re-runninginitis a clean overwrite.
10.7 The full live example
The canonical end-to-end implementation is at otigen/examples/upgradeable-proxy/ — three contracts (proxy + logic-v1 + logic-v2), the Python e2e harness that drives a fresh devnet through an admin-gated upgrade, and assertions that the proxy's value slot survives the logic swap end-to-end while both logic contracts' own storage stays untouched.
11. Hash-based commitments and Merkle proofs
Pyde exposes three hash host fns and a real PQ signature primitive — together they cover every commitment-style pattern devs reach for: airdrops, allowlists, batched offchain-state proofs, content-addressed storage, leaderboard snapshots, and ZK-coupled inclusion claims. This chapter ties the three hashes to their use cases and walks through the canonical pattern (Merkle inclusion) end-to-end so the surrounding tradeoffs aren't buried in a single example file.
11.1 The hash host fn surface
| Host fn | Output | Gas (base + per word) | When to reach for it |
|---|---|---|---|
hash_blake3 | 32 B | 15 + 3/8B | The performance default. ~2 GB/s on x86. Use for event topics, address derivation, merkle proofs, anything the contract recomputes hot. |
hash_poseidon2 | 32 B | 100 + 30/8B | ZK-friendly. ~10× slower than Blake3 native, but generates circuit-friendly outputs. Use for storage slot derivation (the engine does this internally), state-root commitments, and anything you might prove in a ZK circuit later. |
hash_keccak256 | 32 B | 30 + 6/8B | Cross-chain interop only. Use when you're verifying an Ethereum-side artifact (Merkle Patricia proof, EIP-712 hash) and the comparison must agree byte-for-byte with Ethereum. Don't pick this for fresh Pyde-native designs. |
All three have the same shape:
#![allow(unused)] fn main() { #[link(wasm_import_module = "pyde")] extern "C" { pub fn hash_blake3 (in_ptr: *const u8, in_len: i32, out_ptr: *mut u8); pub fn hash_poseidon2(in_ptr: *const u8, in_len: i32, out_ptr: *mut u8); pub fn hash_keccak256(in_ptr: *const u8, in_len: i32, out_ptr: *mut u8); } }
out_ptr must point to at least 32 writable bytes. None of them return a value — the output lands in linear memory at out_ptr. Spec: HOST_FN_ABI_SPEC §7.6.
11.2 Domain separation: prepend a tag, always
If you hash both (claimant, amount) leaves and (left, right) internal nodes with the same function and no distinguishing prefix, an attacker who knows two leaves can claim a forged "leaf" whose 64-byte preimage exactly matches the 64-byte preimage of an internal node. The resulting hash collides — a second-preimage attack against the structure, not the hash function itself.
The fix is a domain-separation tag — a fixed byte prefix that's different for every distinct kind of input:
#![allow(unused)] fn main() { const LEAF_TAG: &[u8] = b"PYDE_LEAF"; const NODE_TAG: &[u8] = b"PYDE_NODE"; fn leaf_hash(claimant: &[u8; 32], amount: u128) -> [u8; 32] { let mut buf = [0u8; 9 + 32 + 16]; buf[..9].copy_from_slice(LEAF_TAG); buf[9..41].copy_from_slice(claimant); buf[41..].copy_from_slice(&amount.to_be_bytes()); let mut out = [0u8; 32]; unsafe { host_fns::hash_blake3(buf.as_ptr(), buf.len() as i32, out.as_mut_ptr()); } out } fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { let mut buf = [0u8; 9 + 32 + 32]; buf[..9].copy_from_slice(NODE_TAG); buf[9..41].copy_from_slice(left); buf[41..].copy_from_slice(right); let mut out = [0u8; 32]; unsafe { host_fns::hash_blake3(buf.as_ptr(), buf.len() as i32, out.as_mut_ptr()); } out } }
Tag length is irrelevant for security (Blake3 mixes input in 64 B chunks regardless). 4–16 bytes is conventional. The structure-level invariant the tag must hold: any two inputs that play different roles in the protocol have non-overlapping byte prefixes. Cross-protocol use too — b"PYDE_FALCON_MSG", b"PYDE_AIRDROP_LEAF", etc. — to keep separate apps from cross-colliding.
This is RFC 9162-style (Certificate Transparency v2) and the same approach OpenZeppelin, EIP-712, and Sigsum all use. Skipping it has bitten production systems.
11.3 Building a Merkle tree off-chain
The contract never builds the tree — it only commits to a root and verifies inclusion against it. The tree-build happens in whatever tool generates the (claimant, amount) allocation list:
root = node_hash(node_AB, node_CD)
/ \
node_AB = node_hash(leaf_A, leaf_B) node_CD = node_hash(leaf_C, leaf_D)
/ \ / \
leaf_A leaf_B leaf_C leaf_D
(alice,100) (bob,200) (carol,300) (dave,400)
Pad odd levels by hashing the lone child with itself (or with a sentinel like [0u8; 32] — pick one and document it; the verifier needs to do the same). Pre-sort the (claimant, amount) list by claimant address for determinism — two different orderings produce two different roots, and a launcher who picks the wrong order will mint an unusable commitment.
The publisher commits root on-chain via a one-shot set_root(bytes32) call. They publish each (claimant, amount, path) tuple via whatever offchain channel makes sense (S3, IPFS, a Discord pin) — the path is just a witness, not a secret, so cheap-and-cheerful hosting is fine.
11.4 Encoding a proof
A merkle proof from leaf i to the root is log₂(N) levels deep. At each level you need:
- The sibling at that level (32 bytes), and
- The position of your current running hash — left or right child (1 bit).
The simplest sound encoding: pack each level as a 33-byte step, [position_byte][sibling_32B]. Total proof length is 33 × depth. For a 1 M-leaf tree, the proof is 33 × 20 = 660 bytes. Cheap.
Two common alternatives, and why I'd avoid them:
- Sorted-pair hashing (OpenZeppelin's default): hash
(min(a, b), max(a, b))at each level so the position bit isn't needed. Saves a byte per level but lets attackers permute proofs — there's only one valid hash for any(a, b)regardless of which sidealives on. Easier to forge if the tree-builder later changes orderings; harder to extend with auxiliary metadata. - Bit-packed positions: stash all the position bits in a single 4-byte prefix, then sibling-array. Saves
depth - 4bytes (negligible at depth 10) at the cost of a manual bit-unpacking loop. Pick this only if proof size genuinely matters (block-space-sensitive proofs, on-chain every tx).
Stick with the byte-per-step encoding unless you have a concrete reason not to.
11.5 Verifying a proof on-chain
The verification loop is short, because the structure pushes complexity offchain. The contract:
- Recomputes the leaf hash from
(caller, amount). - Walks the proof, applying each sibling at the position the byte specifies.
- Compares the final hash against the stored root.
#![allow(unused)] fn main() { fn walk_proof(leaf: [u8; 32], proof: &[u8]) -> [u8; 32] { let mut hash = leaf; let mut i = 0; while i < proof.len() { let position = proof[i]; let mut sibling = [0u8; 32]; sibling.copy_from_slice(&proof[i + 1..i + 33]); hash = if position == 0 { // Running hash is on the LEFT this level. node_hash(&hash, &sibling) } else { // Running hash is on the RIGHT. node_hash(&sibling, &hash) }; i += 33; } hash } #[pyde::entry] fn claim(amount: u128, proof: Vec<u8>) { // Sanity-check proof length BEFORE hashing — saves gas on garbage input. if proof.len() % 33 != 0 { pyde::revert("merkle: malformed proof"); } if proof.len() > 33 * 32 { pyde::revert("merkle: proof too long"); } // cap at 32 levels = 2^32 leaves let claimant = pyde::caller(); let leaf = leaf_hash(&claimant, amount); let computed = walk_proof(leaf, &proof); if computed != stored_root() { pyde::revert("merkle: invalid proof"); } // ... mark claimed, emit event, etc. } }
The #[pyde::entry] macro emits the void-void shim that reads the borsh-encoded calldata via pyde::calldata_*, decodes amount: u128 + proof: Vec<u8>, and dispatches into this body. No hand-rolled extern "C" + pointer-math.
The caller_addr() binding is the trick that makes this safe: the leaf commits to whoever's actually calling, not an address arg. An attacker who steals alice's path can't replay it because their caller_addr() would be different, so the leaf hashes wouldn't match.
Gas cost is 15 base + 3/word per hash_blake3 invocation, times depth per claim. For a 1 M-leaf tree (depth 20) verifying a 73 B node-hash preimage, that's ~20 × (15 + 3×10) = 900 gas for the hashes alone — cheaper than a single sstore.
11.6 Common pitfalls
- Forgetting the sibling-vs-self ordering on odd levels. If the tree-builder and the contract handle odd levels differently (one duplicates the lone child, the other uses zero-padding), roots disagree. Pick one rule. Pin it in a comment.
- Hashing the address as ASCII. The leaf's
claimantfield is 32 raw bytes, NOT the hex-string repr. If your offchain tool hashes the string"0xabc…", the contract — which feeds raw bytes fromcaller()— will recompute a different leaf. - Forgetting to verify proof length. A non-multiple-of-33 proof leaks garbage into the sibling buffer. Always
proof_len % 33 == 0before walking. - Trusting the amount arg without rebinding to caller. If
claim(claimant, amount, proof)accepts the claimant as an argument instead of usingcaller(), anyone who knows alice's path can submit it under their own tx and the verification still passes — but the funds go to whoever the contract pays out to (often the arg, not the caller). Tie the leaf tocaller(). - Reusing the same tree across protocols. Two airdrops sharing a leaf scheme (no protocol-specific tag prefix) means proofs from one can be replayed against the other. Add a protocol identifier to the leaf tag:
b"PYDE_AIRDROP_V1_LEAF".
11.7 Live example
The full pattern — domain-separated hashing, 33-byte step encoding, proof verification, double-claim protection, all-error-path tests — lives in otigen/examples/merkle-claim-airdrop/ on the #[pyde::entry] macro substrate with 10 passing behaviour tests.
12. Composed contracts — when primitives stack
The §7-§11 chapters each cover a primitive in isolation: storage, cross-call, FALCON, proxy, hashing. Real contracts compose them. A DAO needs all five at once. A vesting contract with multisig admin needs three. The composition is not always obvious — pairing FALCON sigs with time-phased state introduces replay surfaces that neither pattern has alone, and inlining a delegate_call into a hash-committed dispatch can corrupt storage if the slot layouts diverge.
This chapter walks through the canonical composed example — otigen/examples/dao-governance/ — and pulls out the four reusable composition patterns it demonstrates. The patterns generalise beyond DAOs to any contract that pairs off-chain authorization with on-chain time-bound execution.
12.1 Why composition is its own concern
A contract built from one primitive is straightforward. A contract built from four primitives has combinatorial failure modes — interactions you can only see with all four in play.
| Primitive alone | Failure mode you get |
|---|---|
| FALCON-signed action | Replay (the sig is reusable across calls) |
| Time-phased state | "What if the call arrives at exactly the boundary?" off-by-ones |
| Hash-committed calldata | "What if the calldata grows between commit and execute?" length-confusion |
| Mapping storage | Composite-key collisions |
A composed contract has all four. The pitfalls don't add — they multiply. A FALCON sig that replay-protects within one DAO can still be replayed across DAOs unless the canonical message includes self_address. A time-phased state machine that's safe with caller-based voting opens a denial-of-service surface when votes become signed (anyone can submit, including spam). A hash-commitment that's collision-resistant alone becomes preimage-attackable when an attacker can choose what gets hashed via a separate call. The patterns below address these interactions directly.
12.2 Anatomy of dao-governance
The full contract is ~450 lines; the high-level shape is just four phases:
1. configure(quorum, voting_duration, signer0_pkh, signer1_pkh, signer2_pkh)
↳ one-shot: locks in 3 signer pubkey hashes + governance parameters
2. propose(target, calldata_hash) → proposal_id
↳ anyone: writes (target, calldata_hash, end_time = now + voting_duration)
into 6 per-proposal storage fields
3. cast_signed_vote(proposal_id, in_favor, canonical_msg, voter_pubkey, sig)
↳ anyone submits (relayer-friendly): host verifies caller's FALCON sig
against the canonical message, increments yes/no tally
4. execute(proposal_id, calldata)
↳ anyone (post-deadline): if quorum + majority + calldata-hash-match,
marks executed + emits Executed event
The composition surface is at the phase boundaries:
- propose → vote — vote sigs bind to
proposal_id, so a sig for proposal 0 can't be replayed against proposal 1. But the binding alone isn't enough; see §12.4. - vote → execute — execute checks both
quorum_metANDyes > no, AND the calldata hash matches. Skip any of these and you have an exploit. §12.6 walks through why. - propose → execute — the
calldata_hashstored at propose-time is the commitment; the bytes supplied at execute-time are the reveal. The contract verifies they match, so neither party can swap calldata between commit and execute. §12.6 again.
12.3 Pattern 1 — signed off-chain action authorization
The classic Web2 + multisig + meta-tx pattern, ported to FALCON:
- Voter computes the canonical message bytes off-chain (their wallet does this).
- Voter signs with their FALCON-512 secret key.
- Voter ships
(canonical_msg, pubkey, sig)to a relayer (Discord bot, web app, whatever). - Relayer submits
cast_signed_vote(proposal_id, in_favor, canonical_msg, pubkey, sig)— pays gas, doesn't need any FALCON key themselves. - Contract recomputes the canonical message from
(proposal_id, in_favor, self_address), verifies the relayer'scanonical_msgmatches, thenfalcon_verify(pubkey, canonical_msg, sig). Tally incremented.
The relayer's gas-paying role is real: a 666-byte FALCON sig + 897-byte pubkey + 64-byte message ≈ 1.6 KB of calldata per vote, costing the relayer ~13K gas in calldata alone before the verify (~50K). Voters never need a funded account. This pattern generalises to any "approve N actions off-chain, submit them in one batch on-chain" flow — airdrops, governance, multisig spend.
Why the sig isn't the only auth check
#![allow(unused)] fn main() { // 1. Reject unknown signers BEFORE verifying — verify is 50K gas, lookup is 100. let voter_hash = poseidon2(&pk_buf); if read_u64(FIELD_SIGNERS, &voter_hash) == 0 { revert(b"UnknownSigner"); } // 2. Reject double-votes BEFORE verify — same gas economy argument. let v_key = voted_key(proposal_id, &voter_hash); if read_u64(FIELD_VOTED, &v_key) != 0 { revert(b"AlreadyVoted"); } // 3. NOW verify. let rc = falcon_verify(pk_buf.as_ptr(), msg.as_ptr(), msg.len(), sig_ptr, sig_len); if rc != 0 { revert(b"BadSignature"); } }
Order matters: cheap structural checks first, expensive cryptographic check last. An attacker who spams unknown-signer votes pays 200 gas per attempt, not 50K. The contract's gas-ddos-resistance is built into the check order.
12.4 Pattern 2 — domain-separated canonical messages
The single most-bitten composition pitfall in production contracts. A FALCON sig binds a public key to a specific message-bytes preimage. If two contracts produce identical preimages for different intents, sigs leak across them — a sig authorizing "vote yes on proposal 3 in DAO A" verifies as "vote yes on proposal 3 in DAO B" if DAO B uses the same canonical-message recipe.
Pyde's canonical message format for dao-governance:
canonical_msg =
"PYDE_DAO_VOTE_V1" (16 bytes — domain-separation tag)
‖ self_address (32 bytes — this DAO's contract address)
‖ proposal_id_be (8 bytes)
‖ in_favor_be (8 bytes)
= 64 bytes total
The three composition guarantees, each one closing an attack:
| Field | Closes |
|---|---|
"PYDE_DAO_VOTE_V1" tag | Cross-protocol replay (a sig for a Pyde airdrop claim can't double as a vote, even if a malicious wallet UI tricks the voter) |
self_address | Cross-DAO replay (alice's sig for DAO A doesn't auth her in DAO B even if she's a signer of both with the same FALCON key) |
proposal_id, in_favor | Per-proposal binding (a yes-vote on #3 doesn't auth a yes-vote on #4) |
Skip self_address and the contract has a real cross-DAO replay bug. Skip the domain tag and you have a real cross-protocol bug. Skip proposal_id and the same sig votes on everything.
Why dao-governance threads the canonical message as a separate arg
#![allow(unused)] fn main() { cast_signed_vote(proposal_id, in_favor, canonical_msg, voter_pubkey, sig) ^^^^^^^^^^^^^^ ALSO supplied? }
Couldn't the contract just construct the canonical message itself from (proposal_id, in_favor, self_address)? Yes — and it does. But the test framework's @sig:NAME:args.IDX DSL signs the raw bytes of one of the call args. To get the framework to produce a real FALCON sig over the canonical bytes, the bytes have to be an arg. So the contract:
- Accepts
canonical_msgas abytesarg. - Reconstructs the expected canonical message from the other args.
- Verifies the supplied bytes match the reconstruction, reverting if not.
- FALCON-verifies the sig against the supplied (== reconstructed) bytes.
Skip step 3 and an attacker can submit a sig over an arbitrary preimage while claiming it authorizes a different action — the contract would count the wrong vote.
In production (no test framework involved), the wallet computes the canonical message once and ships only the sig. The contract reconstructs and verifies. The arg-threading is a test-time convenience.
12.5 Pattern 3 — time-phased state machines via wave_timestamp
A proposal has a natural lifecycle: open → voting closed → executed (or stale). Time gates the transitions.
#![allow(unused)] fn main() { // In propose: let end_time = now() + read_u64(FIELD_VOTING_DURATION, &[]); write_u64(FIELD_PROPOSAL_END_TIME, &id_key, end_time); // In vote: if now() >= end_time { revert(b"VotingClosed"); } // In execute: if now() < end_time { revert(b"VotingStillOpen"); } }
now() is a contract-side wrapper around wave_timestamp — the committee-attested wall-clock, identical across all validators. Deterministic; no "what time did your node see?" race.
Boundary conditions: < vs <=
vote check: now() >= end_time → revert
execute check: now() < end_time → revert
At exactly now == end_time:
- vote sees `end_time >= end_time` → revert (voting closed)
- execute sees `end_time < end_time` → does NOT revert (voting open)
So at the exact boundary, voting closes and execution opens in the same wave. No "one-wave window of nothing." Pick this direction explicitly when designing the gates — the alternative (votes open at now() <= end_time, execute requires now() > end_time) creates a one-wave gap where neither operation is valid, which has surfaced as a real bug in production governance contracts.
wave_timestamp is in seconds; time-window math fits u64 easily
Pyde's wave_timestamp returns unix seconds (committee-attested). A u64 covers ~5×10¹¹ years. Adding voting_duration to now() cannot realistically overflow at any input the contract would accept. The contract still uses saturating_add defensively — cheap, makes the bound explicit.
12.6 Pattern 4 — hash-committed deferred dispatch
Proposals announce what they'll do at execute-time without revealing it cheaply. The mechanism:
#![allow(unused)] fn main() { // At propose: write_slot(FIELD_PROPOSAL_CALLDATA_HASH, &id_key, &calldata_hash); // At execute: let actual = hash_blake3(&calldata_bytes); if actual != stored_hash { revert(b"CalldataMismatch"); } }
The contract never has to store the calldata bytes themselves — just the 32-byte hash. Why this is genuinely useful:
- Storage cost: a 4 KB calldata bundle would cost ~129K gas to sstore (5K + 32×4096 = 5K + 131K). Storing the hash is 5K base + 32×32 = ~6K. 20× cheaper for any non-trivial calldata.
- Forward compatibility: the contract can dispatch arbitrary future calldata shapes without redeploy. The proposer commits to bytes; whoever executes provides those bytes verbatim. If the cross_call ABI evolves, only the proposer + executor need to coordinate — the contract stays stable.
- Auditability: the hash on-chain is a permanent record. Anyone can recompute it from the (publicly-archived) calldata and verify what the proposal was actually about, regardless of UI claims.
Why the hash check happens after quorum/majority checks
#![allow(unused)] fn main() { // Revert ladder, in order: if proposal_id >= count { revert(b"UnknownProposal"); } if now() < end_time { revert(b"VotingStillOpen"); } if read_u64(EXECUTED) != 0 { revert(b"AlreadyExecuted"); } if yes < quorum { revert(b"QuorumNotMet"); } if yes <= no { revert(b"VoteFailed"); } let actual = blake3(&calldata); // expensive: 15 + 3/word if actual != stored { revert(b"CalldataMismatch"); } }
blake3 is cheap (~3 gas per 8 bytes) but every host fn pays a 15 gas base. The order matters at scale: structural checks (proposal exists, time, quorum) are 10× cheaper than the hash. An attacker who spams execute with wrong calldata pays the quorum-check gas, not the hash gas.
The 4 KB cap
#![allow(unused)] fn main() { const MAX_CD: usize = 4096; if cd_len > MAX_CD { revert(b"CalldataTooLong"); } }
Pyde's bytes-typed args are theoretically up to 16 KB; capping at 4 KB protects the stack buffer the contract uses to store calldata for hashing. Without the cap, an attacker passes a 12 KB blob and the contract's [0u8; MAX_CD] allocation overflows. Set the cap to match the proposal patterns you actually expect; 4 KB covers a typical cross-call signature + args.
12.7 Composition pitfalls (a checklist)
Working through composed contracts, these are the failures that look obvious in hindsight but bit me writing dao-governance:
- Reverting after state mutation. If
falcon_verifyhappens AFTERyes_votes += 1, a verify failure means storage is corrupted. Pyde's tx overlay rolls back automatically on trap, but only if the trap reaches the boundary — emit_event won't trap on its own. Order: mutate state LAST, after every check. - Domain tag drift.
b"PYDE_DAO_VOTE_V1"is 16 bytes.b"PYDE_DAO_VOTE_V2"is also 16 bytes but a different preimage. If you ship V2 logic and forget to bump the tag, every old V1 sig is silently still valid against the new contract. Bump the tag whenever the canonical-message shape changes; treat it as a version pin. - Composite key ordering.
voted[(proposal_id, voter_hash)]packs into bytes viaproposal_id_be ‖ voter_hash. Reverse the order and you have a different slot — your "already-voted" check misses, double-vote works. Pick an order, document it, never reverse. - Single-shot init left unlocked.
configurechecksread_u64(FIELD_CONFIGURED) != 0— if you forget the flag write, anyone can re-configure the DAO. The flag is the single most security-critical line in the file. Test it explicitly (second_init_reverts). - Auth check order. Cheap checks first, expensive last. A
falcon_verifyupstream of anis_signerlookup wastes 50K gas per attacker probe. block_timestampvswave_timestamp. Pyde renamed this in 2026-05. If you copy old EVM contracts and importblock_timestamp, the contract fails to instantiate. Usewave_timestamp. (The handful of canonical examples in the catalog all use the new name; copying from those avoids the trap.)
12.8 Live example
The full pattern — every check ordered correctly, all 16 behaviour tests covering happy paths + revert paths + boundary conditions + composition surfaces — is in otigen/examples/dao-governance/. It's the canonical reference for any contract that pairs FALCON-signed authorization with time-bound on-chain execution.
Scaffolding a starting point:
$ otigen new my-dao --from dao-governance
$ cd my-dao
$ cargo build --target wasm32-unknown-unknown --release && otigen test
The contract builds + tests pass out of the box; edit from there.
13. How the host reads it
The host (Pyde's engine, in Rust, hosted on top of wasmtime) registers cross_call as a linker function during executor initialization. The function signature on the host side mirrors the WASM signature, plus a Caller handle that gives access to the contract's linear memory.
13.1 Engine-side cross_call handler
#![allow(unused)] fn main() { // Register cross_call with the wasmtime linker. This wires the WASM-side // import `pyde::cross_call` to the Rust closure below. linker.func_wrap( "pyde", "cross_call", | // The Caller handle is wasmtime's way of giving host functions // access to the calling instance's exports — most importantly // its linear memory. mut caller: Caller<'_, HostState>, // The eight integer parameters from the WASM side, in exactly // the order the contract passed them. target_ptr: i32, fn_name_ptr: i32, fn_name_len: i32, calldata_ptr: i32, calldata_len: i32, value_ptr: i32, gas_limit: i64, return_data_out_ptr: i32, return_data_out_len_ptr: i32, | -> wasmtime::Result<i32> { // ── 1. Grab the contract's linear memory ───────────────── // // Every Pyde contract exports its linear memory under the // name "memory". (This is the wasm32 default; the linker // emits this export automatically.) let memory = caller .get_export("memory") .and_then(|e| e.into_memory()) .ok_or_else(|| wasmtime::Error::msg("no memory export"))?; // ── 2. COPY the 32-byte target address out of linear memory ── // // memory.read performs a bounds-checked memcpy from the WASM // instance's linear memory into the host's `target` array. // If [target_ptr, target_ptr + 32) is out of bounds, this // returns Err(MemoryOutOfBounds) and the WASM call traps. let mut target = [0u8; 32]; memory.read(&caller, target_ptr as usize, &mut target)?; // ── 3. COPY the function name (variable length) ────────────── // // We trust fn_name_len because the bounds check inside // memory.read will catch any out-of-bounds; we cap it at // a sane max upstream (in attribute validation) to prevent // accidental gigabyte allocations. let mut fn_name = vec![0u8; fn_name_len as usize]; memory.read(&caller, fn_name_ptr as usize, &mut fn_name)?; let fn_name_str = core::str::from_utf8(&fn_name) .map_err(|_| wasmtime::Error::msg("fn_name not UTF-8"))?; // ── 4. COPY the calldata ───────────────────────────────────── let mut calldata = vec![0u8; calldata_len as usize]; memory.read(&caller, calldata_ptr as usize, &mut calldata)?; // ── 5. COPY the 16-byte value (u128 little-endian) ─────────── let mut value_bytes = [0u8; 16]; memory.read(&caller, value_ptr as usize, &mut value_bytes)?; let value = u128::from_le_bytes(value_bytes); // ── 6. CHARGE gas for the byte-copy work ────────────────────── // // From HOST_FN_ABI_SPEC §7.8: 1,000 base + 8 per byte of // calldata. The base covers the dispatch overhead; the // per-byte rate covers the memcpy + the sub-call's serialized // input handling. caller.data_mut().consume_gas( 1_000 + 8 * (calldata_len as u64), )?; // ── 7. Dispatch the actual sub-call ─────────────────────────── // // This recurses into the engine: the engine pushes a nested // per-tx overlay, loads the target contract's WASM module // (from the module cache), invokes its named function with // the calldata as input, and either merges the overlay on // success or discards it on revert. let dispatch = caller.data_mut().dispatch_cross_call( &target, // target contract address fn_name_str, // function name &calldata, // serialized inputs value, // value to forward gas_limit, // sub-call gas budget ); // ── 8. Encode the result back into linear memory ────────────── match dispatch { // Sub-call returned successfully with some return data. Ok(return_data) => { // Write the return data into the contract's pre-allocated // output buffer. If the buffer is too small, the wasmtime // bounds check would catch it — but we should also enforce // that the contract's `return_data_out_ptr` had enough // capacity. (In practice, the contract should always // provision a large enough buffer or read the length // first and re-call.) memory.write( &mut caller, return_data_out_ptr as usize, &return_data, )?; // Write the actual length into the contract's // return_data_out_len_ptr slot, so it knows how many // bytes are meaningful. let len_bytes = (return_data.len() as i32).to_le_bytes(); memory.write( &mut caller, return_data_out_len_ptr as usize, &len_bytes, )?; // Status code 0 = success. Ok(0) } // Sub-call failed with an error code. Err(error_code) => { // We still write a 0 into the length slot so the contract // doesn't accidentally read stale bytes from return_buf. let zero = 0i32.to_le_bytes(); memory.write( &mut caller, return_data_out_len_ptr as usize, &zero, )?; // Return the negative error code through the WASM call // return value. The contract pattern-matches on this. Ok(error_code) } } }, )?; }
13.2 Why this design
Three properties fall out of this shape:
-
The host never trusts the contract's pointers blindly. Every
memory.readandmemory.writeis bounds-checked by wasmtime. A malicious contract can pass nonsense offsets; the worst it can do is trap. -
The byte-copy cost is metered. The 1,000-base + 8-per-byte formula isn't arbitrary — it directly reflects the real wall-clock cost of the memcpy operations in steps 2–5 + step 8. Big calldata = more wall-clock = more gas.
-
The sub-call is a recursive engine entry. When
dispatch_cross_callruns, the engine handles the target's WASM exactly the same way it handled the calling contract: instantiate (or fetch cachedModule), push overlay, invoke, charge gas, return. The target'scross_calls in turn recurse further. The only bound on call depth is the per-tx gas limit + an explicit stack-depth check (HOST_FN_ABI_SPEC §3.5b).
14. The end-to-end flow
Putting §6 (contract-side staging), §7 (the cross_call invocation), and §8 (host-side handling) together:
─────────────────────────────────────────────────────────────────────────
CONTRACT A (e.g., a DEX router calling token.transfer)
─────────────────────────────────────────────────────────────────────────
fn perform_swap(...) {
┌─────────────────────────────────────────────────────┐
│ Stack frame in linear memory: │
│ calldata: [u8; 48] ← recipient ++ amount │
│ zero_value: [u8; 16] ← no PYDE attached │
│ return_buf: [u8; 32] ← reserved for return data │
│ return_len: i32 ← reserved for length │
└─────────────────────────────────────────────────────┘
cross_call( (a) WASM call ABI crosses
target_ptr = 0x1000, the boundary as 9 × i32
fn_name_ptr = 0x1080, + 1 × i64 raw value
fn_name_len = 8, words. NO data is
calldata_ptr = 0x1100, copied yet — just
calldata_len = 48, offset numbers.
value_ptr = 0x1200,
gas_limit = 100_000,
return_data_out = 0x1300,
return_len_out = 0x1400,
);
}
│
▼ wasmtime traps from WASM → registered host fn
─────────────────────────────────────────────────────────────────────────
ENGINE (Rust on top of wasmtime)
─────────────────────────────────────────────────────────────────────────
(b) Host now reads contract A's linear memory through wasmtime API.
Each memory.read is a real memcpy from contract linear memory
into the host's Rust heap.
memory.read(0x1000, 32) → target = [u8; 32] (target contract)
memory.read(0x1080, 8) → fn_name = "transfer"
memory.read(0x1100, 48) → calldata = recipient ++ amount
memory.read(0x1200, 16) → value = 0u128
(c) Engine charges gas: 1,000 + 48 * 8 = 1,384 gas debit
from contract A's remaining budget.
(d) Engine looks up `target` in the contract registry.
Loads target's compiled wasmtime::Module from the cache.
Pushes a nested per-tx overlay onto the state stack.
─────────────────────────────────────────────────────────────────────────
CONTRACT B (target = token contract)
─────────────────────────────────────────────────────────────────────────
(e) Engine instantiates target's module with a fresh wasmtime::Store.
Engine invokes target's exported `transfer` function with the
48-byte calldata loaded into target's linear memory at the
conventional calldata location (or copied on-demand via
pyde::calldata_copy host fn — depends on target's ABI choice).
target executes — does its sload(balances[sender]) /
sload(balances[recipient]) / sstore / emit_event / etc.
(f) target returns (or traps).
On return: target's overlay merges into contract A's overlay.
On trap: target's overlay is discarded; contract A's overlay
is preserved (this is the per-call rollback semantics).
─────────────────────────────────────────────────────────────────────────
ENGINE (after sub-call resolves)
─────────────────────────────────────────────────────────────────────────
(g) Engine writes target's return data back into contract A's
linear memory:
memory.write(0x1300, &return_data)
memory.write(0x1400, return_data.len().to_le_bytes())
(h) Engine returns through wasmtime: i32 status code
(0 on success, negative on error).
│
▼ wasmtime returns from host fn → WASM
─────────────────────────────────────────────────────────────────────────
CONTRACT A resumes
─────────────────────────────────────────────────────────────────────────
(i) Contract A reads the status code from the WASM ABI return.
If 0: contract A reads return_buf[..return_len] from its own
linear memory to extract any return data.
If negative: contract A pattern-matches on the error code and
decides whether to revert / retry / propagate.
14.1 What you actually pay for
For a single cross_call with 48 bytes of calldata and an empty return:
| Cost component | Amount |
|---|---|
cross_call dispatch base | 1,000 gas |
| Calldata byte-copy (48 × 8) | 384 gas |
Sub-call's actual gas_used | (varies; e.g., 5,000 for a typical transfer) |
| Total deducted from caller | ~6,384 gas |
The sub-call's gas_used is debited from the caller's remaining budget regardless of whether the sub-call succeeded or reverted — per HOST_FN_ABI_SPEC §7.8. This prevents an attacker from triggering expensive sub-calls and then reverting to avoid payment.
15. Common pitfalls
A non-exhaustive list of things that have bitten real Pyde contracts during development:
15.1 Endianness mismatch
Symptom: A contract writes amount: u128 = 100 via amount.to_be_bytes(), the host reads via u128::from_le_bytes — you end up with a 16-byte big-endian on-wire representation interpreted as little-endian. The host sees 0x6400000000000000_00000000_00000000 instead of 100.
Fix: Always little-endian on the wire. to_le_bytes / from_le_bytes on both sides.
15.2 Returning a pointer to a dropped local
#![allow(unused)] fn main() { // ❌ BROKEN: `local_buf` is dropped at the end of this function. // The host fn copies bytes out synchronously, so within the call // itself this works — but if you stash the offset and try to read // it later (e.g., from another host fn callback), the offset now // points into garbage. pub fn broken_pattern() -> i32 { let local_buf = [42u8; 32]; local_buf.as_ptr() as i32 // ← do NOT return this } }
Fix: For data that must survive past the current call, use static mut (§6.2) or heap allocation (§6.3). For data that only needs to live through one host call, stack-allocated is fine.
15.3 Forgetting to provision the return-length slot
Symptom: cross_call writes the actual return length into return_data_out_len_ptr, but you passed an uninitialized or shared slot — leading to garbage values for the length check.
Fix: Always declare let mut return_len: i32 = 0; (or equivalent in Go/AS) immediately before the call. Don't re-use a slot across multiple cross-calls without re-zeroing.
15.4 Returning a too-small buffer
Symptom: Sub-call returns 256 bytes of data; your return_buf is 32 bytes; wasmtime traps with MemoryOutOfBounds when the host tries to write past your buffer.
Fix: Size return buffers to the documented worst case for the target function. For unknown / variable-size returns, do a two-pass approach: first call with return_buf = [] and read the length; second call with a buffer of that size. (Pyde's spec defers the formal "buffer too small" semantics to per-host-fn definitions — check the spec for each host fn before assuming.)
15.5 Forgetting that pointers are 32-bit
#![allow(unused)] fn main() { // ❌ BROKEN: `let ptr: i64 = my_array.as_ptr() as i64;` // On wasm32, my_array.as_ptr() is a 32-bit value. Casting to i64 // sign-extends from i32 — which works numerically — but when passed // to a host fn declared with i32, the higher bits are silently // dropped, which is not what you want for any non-trivial pointer // pattern. let ptr: i32 = my_array.as_ptr() as i32; // ← correct }
Fix: Always cast pointers to i32 (or usize in AS / TinyGo). Host fn signatures use i32 for pointers; matching exactly prevents subtle type-coercion bugs.
15.6 Importing a host fn that doesn't exist
Symptom: Deploy fails with ERR_FORBIDDEN_IMPORT.
Fix: Every imported function name must appear in the canonical ABI table (HOST_FN_ABI_SPEC §7 and §8). The deploy-time validator rejects unknown imports. Typos like pyde::s_load (extra underscore) vs pyde::sload (no underscore) are a frequent culprit.
15.7 Calling a parachain-only host fn from a non-parachain contract
Symptom: Deploy fails with ERR_FORBIDDEN_IMPORT for functions like parachain_storage_read, send_xparachain_message, threshold_encrypt, etc.
Fix: These functions are gated to parachain-typed modules at deploy time (HOST_FN_ABI_SPEC §9.2). If your contract needs them, declare type = "parachain" in otigen.toml; otherwise refactor to avoid the dependency.
15.8 Leaving debug_log calls in a production bundle
Symptom: otigen build --strict or otigen deploy fails with import pyde.debug_log is a test-only host fn (forbidden on chain).
Fix: pyde::debug_log is a test-only host fn (HOST_FN_ABI_SPEC §9.3). otigen build (default) and otigen test accept it so the dev loop works. The production gate fires at otigen build --strict and at otigen deploy (which sets strict implicitly). Strip the calls — or guard them behind #[cfg(feature = "debug")] — before deploying.
#![allow(unused)] fn main() { // Development: print intermediate values during otigen test. #[link(wasm_import_module = "pyde")] extern "C" { fn debug_log(msg: *const u8, len: i32); } fn dump(label: &str, value: u64) { let line = format!("{label}={value}"); unsafe { debug_log(line.as_ptr(), line.len() as i32); } } // In a function: dump("alice_balance", read_u128(FIELD_BALANCES, &alice) as u64); }
Run otigen test -v and watch stderr for [debug] <fn>: alice_balance=100. Strip these calls (or guard them behind #[cfg(feature = "debug")]) before otigen deploy. A grep over the source tree (grep -rn debug_log src/) is sufficient.
16. References
- HOST_FN_ABI_SPEC v1.0 — normative ABI specification.
- Chapter 3 — Execution Layer — wasmtime runtime architecture, fuel metering, module caching.
- Chapter 5 — Otigen Toolchain — how
otigen buildinvokes the language toolchain and emits the WASM binary. - PARACHAIN_DESIGN — parachain framework: registration, lifecycle, extended ABI surface, cross-parachain messaging.
- OTIGEN_TEST_SPEC — test framework that runs contracts under the same wasmtime configuration the chain uses.
- WebAssembly Core Specification — the upstream WASM spec for value types, linear memory, instruction semantics.
- Examples catalog:
pyde-book/otigen/examples— full table of every canonical example with what each demonstrates, host fns exercised, and per-language test counts. otigen/examples/counter— the canonical minimal-viable Rust template demonstrating §2.3, §6.1, §6.2 patterns end-to-end. Per-languagecounter-{go,as,c}siblings live underexamples/and run the same TOML test suite against each port; the four ports stay aligned by hand (no shared scaffold today).otigen/examples/erc20-token— full ERC20-style fungible token on the macro substrate. Canonical real-contract reference: exercises typed-arg marshalling (address/uint128) via#[pyde::entry], three storage layouts (scalar / mapping / composite-key) viapyde::declare_storage!(), multi-topic events viapyde::declare_events!(), and thetransfer_fromallowance flow.otigen/examples/simple-multisig— 3-signer FALCON-512 multisig (§9 canonical example).otigen/examples/upgradeable-proxy— upgradeable proxy viadelegate_call(§10 canonical example).otigen/examples/merkle-claim-airdrop— Merkle-tree airdrop claim withhash_blake3host fn (§11 canonical example).otigen/examples/dao-governance— composed example: FALCON-signed votes + time phases + hash-committed execution (§12 canonical example).- RFC 9162 — Certificate Transparency v2 — domain-separation conventions for hash-based commitment trees (§11.2 background).
SDK Author Guide
Audience: language-community leads who want to bring their language (TypeScript, Go, AssemblyScript, Zig, Move, ...) to Pyde as a first-class contract-writing target.
Status: v1.0 (draft). Mirrors the surface of pyde-host + pyde-storage-macros + #[pyde::entry] in otigen (the canonical Rust SDK). When the Rust SDK lands a new convention, this guide is the canonical place we document the cross-language equivalent.
Pyde itself ships no per-language SDKs beyond the Rust reference. The chain provides a stable host-function ABI (HOST_FN_ABI_SPEC) and a stable bundle format (OTIGEN_BINARY_SPEC); everything above that is a community surface. This guide is the contract a community SDK must satisfy so the resulting bundles deploy + execute identically to the reference.
1. What an SDK provides
A complete language SDK gives an author three things:
- Host-fn import wrappers — typed in the language's idiom (e.g., a Go
package pydeexposingSstore(...), an ASclass Pyde { static sload(...) }, a Zigpub fn sload(...)). - An
entrydecorator / macro — wraps the author's function in the() -> ()shim required by §3.0 of HOST_FN_ABI_SPEC. Decodes calldata into the author's declared params; encodes the return value into apyde::returncall. - A
declare_storagedecorator / macro — generates typed accessors from the[state]schema inotigen.toml, so authors writestorage::balances().write(&from, amount)instead ofpyde::sstore_map1(...)directly.
A minimal SDK can ship just (1) — authors will hand-write the entry shim and call host fns directly. Most language communities will want (2) at least; (3) is the polish that makes day-to-day development feel native.
2. The four invariants every SDK must hold
These are non-negotiable. Bundles that violate them won't deploy.
2.1 () -> () WASM signature for every export
Every function the contract exposes to the chain MUST have WASM type () -> (). The chain's deploy validator rejects anything else.
Args come in via calldata_size + calldata_copy (HOST_FN_ABI_SPEC §7.4). Returns go out via pyde::return (§7.7). See §3.0 for the rationale.
What this means concretely for an SDK:
# Generic pseudocode for what `@pyde.entry` must emit
@pyde.entry
function deposit(amount: u128, to: Address) -> Receipt:
# Author writes the natural signature above.
# SDK rewrites it to:
function deposit(): # WASM signature: () -> ()
calldata_len = pyde.calldata_size()
buf = allocate(calldata_len)
limit = encode_u32_le(calldata_len)
pyde.calldata_copy(buf, limit)
amount, to = decode_calldata(buf, [u128, Address])
result = __original_deposit(amount, to)
return_bytes = encode_return_value(result)
pyde.return(return_bytes, len(return_bytes))
The decoder/encoder choice (next section) is up to the SDK but must be deterministic and consistent across the SDK's own entry + cross_call surfaces.
2.2 Borsh-canonical calldata + return encoding
The Rust reference SDK uses borsh v1 (borsh::BorshSerialize / BorshDeserialize) for the calldata-tuple and the return-value encoding. SDK authors should follow this convention unless there is a strong reason not to.
Why borsh and not "negotiate per SDK":
- Cross-SDK interop. A Go contract calling a Rust contract via
cross_callneeds both sides to encode the calldata identically. The chain doesn't impose this; it's an SDK-layer convention. Sharing borsh means a Go author can call a Rust contract without writing a Rust-specific shim, and vice versa. - Tooling.
otigen call --args <hex>and the canonical e2e harnesses inexamples/storage-stress/produce borsh-encoded calldata. A custom encoding would force every author to ship a CLI helper. - The chain's own RPC.
pyde_call's data field is borsh-encodedCallPayload { function: String, calldata: Vec<u8> }. Thecalldatainner Vec is whatever the SDK author chose — but the chain handles borsh for the outer envelope regardless.
Borsh v1 implementations exist for: Rust (borsh), Go (github.com/near/borsh-go), TypeScript (borsh-ts), C++, Python, Java/Kotlin, Swift, AssemblyScript (community ports). For languages without an existing borsh library, porting the v1 spec (a single-page document, ~200 lines of code) is the standard path.
If your SDK must use a different encoding (e.g., a language where borsh isn't viable), document it explicitly: any contract built with your SDK becomes a closed-world ecosystem — cross-callable only by callers that speak your encoding. This is a real cost; weigh it against the cost of porting borsh.
2.3 Host-fn import declarations match HOST_FN_ABI_SPEC exactly
The deploy validator (in otigen-abi/src/host_fns.rs) checks that every imported host function matches its declared signature. Name-mismatched imports fail at deploy time with DeployError::ForbiddenImport (engine/crates/wasm-exec/src/deploy.rs); arity / type mismatches surface as wasmtime instantiate-time link errors rather than a dedicated deploy-validator variant.
The canonical signature table is in HOST_FN_ABI_SPEC §7 + §10. Mirror it precisely in your SDK's import declarations.
Common pitfalls:
calldata_copyis 2-arg, not 3. Signature:(out_ptr: i32, out_len_ptr: i32) -> i32. The contract writes the buffer limit (LE u32) atout_len_ptr; the host caps at that limit, copies bytes, and writes the actual length back. The Rust SDK shipped a 3-arg version briefly; if you're porting from an old reference, fix this.- Multi-byte values are always LE. WASM linear memory is little-endian; the host expects LE everywhere (HOST_FN_ABI_SPEC §3.2).
- Pointers are
i32. WASM32 — even though the Rust SDK declares*mut u8in extern "C" decls (which lowers to i32), the chain sees i32.
2.4 Bundle assembly: pyde.abi custom section
The chain reads the contract's ABI from a WASM custom section named pyde.abi carrying a borsh-encoded ContractAbi (HOST_FN_ABI_SPEC §3.7). Without this section, the deploy validator rejects the bundle.
The canonical bundle-assembly pipeline lives in otigen-abi (Rust). SDK authors have two options:
- Delegate to
otigen build(recommended). Author'sotigen.toml+ compiled.wasmgo through the same toolchain pipeline that every other contract uses. The SDK only needs to emit a.wasmwith the right exports —otigen buildhandles ABI parsing, custom-section insertion, and bundle wrapping. - Build the bundle yourself. Possible if your language community wants a single-binary toolchain that doesn't depend on
otigen. You must:- Serialize the borsh-encoded
ContractAbiexactly asotigen-abiwould (the layout is stable; seeengine/crates/types/src/abi.rsfor the canonical Rust struct). - Insert the custom section using a WASM-encoder library (e.g.,
wasm-encoderin Rust,binaryenin C++,binaryen-loaderfor Node). - Verify with
otigen verify <bundle>before shipping.
- Serialize the borsh-encoded
Option 1 is what AssemblyScript, TinyGo, and the Rust reference all do today. Option 2 is open as a future direction; no language community has taken it yet.
3. Reference implementation surface (Rust)
The Rust SDK in pyde-net/otigen is the canonical reference. When this guide is ambiguous, the Rust source is the source of truth.
Key files:
| Concern | File | What it shows |
|---|---|---|
| Entry shim | crates/pyde-entry-macros/src/lib.rs | How #[pyde::entry] rewrites a fn deposit(amount: u128) into a () -> () export with calldata decode + return encode |
| Storage codegen | crates/pyde-storage-macros/src/lib.rs | How declare_storage!() reads otigen.toml at compile time + emits typed accessors |
| Host-fn extern decls | crates/pyde-host/src/lib.rs | All 40+ host-fn signatures Rust-side, matching HOST_FN_ABI_SPEC §7 |
| Bundle assembly | crates/otigen-abi/src/build.rs | How ContractAbi is built from otigen.toml |
| Custom section insertion | crates/otigen-abi/src/section.rs | How the pyde.abi section is appended to the .wasm (inject / extract / extract_required) |
| Reference contract | examples/storage-stress/ | Exercises every storage type, every map arity, complex multi-slot logic, delete ops |
A reasonable porting strategy:
- Start with host-fn imports. Mirror the signature table; verify with a trivial contract that does one
sstore_scalarand deploys. - Add the entry shim. Borsh-decode tuple of params, invoke the inner function, borsh-encode the return. Test with a no-arg entry first (
get-style), then an arg-taking entry (set(value: u64)). - Add the storage accessor codegen. Read
otigen.toml's[state]schema, emit one accessor per field. Run the equivalent ofexamples/storage-stress/tests/stress_e2e.pyagainst your SDK to verify round-trip. - Add
otigen.tomlintegration. If you're usingotigen build, you're done after (3). If you're shipping a stand-alone toolchain, add bundle assembly + custom-section insertion last.
4. Quality bar
A community SDK should pass the following before being recommended publicly:
- All 30 storage-stress assertions round-trip end-to-end against
otigen devnet. The reference suite is atexamples/storage-stress/tests/stress_e2e.py. Port the assertions to your language's test harness; the asserted shapes are language-neutral. otigen verify <bundle>passes for every example you ship. This re-validates the WASM features, the import allowlist, thepyde.abicustom section, and the bundle manifest.- Cross-call interop with the Rust SDK. Author a two-contract example: contract A (your SDK) calls contract B (Rust SDK) via
cross_call. If borsh-canonical encoding is correct, both decode each other's calldata cleanly. - Forbidden imports are absent. The deploy validator rejects any import outside the allowlist (
crates/otigen-abi/src/host_fns.rs). Usewasm-objdump -j Importor equivalent to confirm your bundles don't accidentally drag inwasi_snapshot_preview1,env, or any other non-pydeimport. - Determinism. No floats in chain logic (HOST_FN_ABI_SPEC §6), no calls to non-deterministic host APIs (random, time, env). Your SDK shouldn't make these accessible; if your language stdlib needs guarding, document it.
5. Where to publish
- Repo: Your SDK lives under the language community's namespace (e.g.,
pyde-go/,pyde-ts/). Pyde Network maintains pyde-net/otigen only. - Cross-link: Once your SDK is ready, open a PR against pyde-net/pyde-book adding your SDK to the Developer Tools chapter under "Community SDKs". Include the repo URL, supported language version, target audience, and a one-line summary of any deviations from the canonical surface.
- Versioning: SDKs follow their own version trains. Pin against a specific
HOST_FN_ABI_SPECversion (currently v1.0); call out incompatibilities in your release notes when the spec rolls forward.
6. Open questions
A handful of surfaces are intentionally underspecified in v1; we'll close them once the first non-Rust SDK lands and exercises the gaps.
- Schema vocabulary extension. The current
ScalarTypeset (u8..u128,i8..i128,bool,address,hash32,bytes,string,vec(<fixed>)) covers the storage-stress matrix. Languages with native richer types (Move-style structs, AS classes) will want a sugar layer overbytes. The convention is up to your SDK; document it explicitly. - Cross-language struct encoding. Borsh handles
(u128, [u8; 32])tuples cleanly across languages, but Rust enums (sum types) don't have a native equivalent in every language. Until a cross-language story exists, treat enums as borshu8 tag + payloadand document the layout. - Native multisig + session-key support. Both are v2 surfaces (Programmable Accounts). When they land, this guide will get a new section on how SDKs surface AuthKey shapes to authors.
7. References
- HOST_FN_ABI_SPEC §3.0 —
() -> ()entry-point WASM signature - HOST_FN_ABI_SPEC §3.7 —
pyde.abicustom section layout - HOST_FN_ABI_SPEC §7 — full host-fn catalog with signatures + gas costs
- HOST_FN_ABI_SPEC §10 — gas table
- OTIGEN_BINARY_SPEC — bundle format
- WASM_AUTHOR_GUIDE — author-facing guide to writing contracts (the audience downstream of your SDK)
- examples/storage-stress — the canonical SDK-acceptance contract
Pyde Otigen Toolchain Binary Specification
Version: v1.0 (draft) Status: Authoritative for v1 mainnet. Subject to revision until mainnet genesis; frozen at v1 launch and only extended in backwards-compatible ways thereafter.
This document is the canonical specification of the otigen developer toolchain binary — the command-line program contract authors use to scaffold projects, drive language-specific builds, validate against the chain ABI, sign and submit deploys, manage wallets, and interact with running networks.
Where HOST_FN_ABI_SPEC.md defines the binary surface between the WASM execution layer and contract code, this document defines the surface between the author and the chain.
If the implementation and this document disagree, this document is authoritative. Implementation bugs are bugs in otigen, not in the spec.
For the narrative overview, see Chapter 5 — Otigen Toolchain.
1. Scope
This spec defines:
- The subcommand catalog — every
otigen X Y Zcommand, its flags, semantics, and exit codes - The
otigen.tomlschema — every key, type, default, and validation rule - The per-language build pipeline — exactly how
otigeninvokes Rust / AssemblyScript / Go / C compilers - The
pyde.abicustom-section injection — howotigenintegrates ABI metadata into the WASM output - The wallet integration — keystore format, FALCON signing pipeline, key rotation
- The deploy / upgrade / lifecycle flow — what transactions
otigensubmits and how - The artifact format — the deploy bundle structure (
.wasm+ manifest) - The network configuration — RPC endpoints, chain IDs, default gas
- The CI / scripting interface — JSON output mode, exit codes
- The versioning rules —
otigenbinary version vs chain ABI version compatibility
This spec does not define:
- The Host Function ABI (see HOST_FN_ABI_SPEC.md)
- Language compiler internals (those belong to upstream — rustc, asc, TinyGo, clang)
- The chain's transaction wire format (see Chapter 11 — Account Model)
- Per-language SDKs —
otigenis not an SDK; it's a build harness (see PARACHAIN_DESIGN §10 for the no-SDK rationale)
2. What otigen is and isn't
Is:
- A build harness: it invokes the language compiler the author already has installed, then post-processes the output WASM.
- A deploy client: it signs, submits, and tracks lifecycle transactions against a Pyde network.
- A wallet: it manages FALCON-512 keypairs in an encrypted keystore.
- A REPL: it offers an interactive shell for querying state, calling contracts, and debugging.
- A contract-behaviour test runner:
otigen testexecutes WASM in a wasmtime sandbox against a TOML-driven test spec, with mock implementations of every host function (see OTIGEN_TEST_SPEC).
Is NOT:
- A language compiler.
otigendoes not parse Rust / AssemblyScript / Go / C. It calls the language's own compiler. - A language-specific SDK. There are no first-party Rust, TypeScript, AssemblyScript, etc. bindings shipped by
otigen. Author writesexterndeclarations against the Host Function ABI themselves; canonical example projects show the idiom. - An IDE. Authors use their language's standard IDE tooling (rust-analyzer, AssemblyScript LSP, gopls, clangd).
otigenis invoked from the command line or from a project'snpm run/cargo runscript. - A language-native unit-test runner.
cargo test/npm test/go testare still the right choice for pure helpers (math, parsing, formatting).otigen testcomplements them at the behaviour-and-state-changes layer, not the function-internals layer.
3. Subcommand catalog
otigen <subcommand> [subsubcommand] [args] [flags]
All subcommands accept the global flags:
| Flag | Effect |
|---|---|
-v, --verbose | Verbose logging. Counter flag — -v info, -vv debug. otigen test extends the ladder to -vvv (per-call traces) and -vvvv (storage diffs); see §3.11. |
-q, --quiet | Suppress non-error output |
--json | Output structured JSON (for CI / scripting) |
--network <name> | Override the default network (default: read from otigen.toml → [network.default]) |
--keystore <path> | Override the default keystore location (default: ~/.pyde/keystore.json) |
--config <path> | Override the default config path (default: ./otigen.toml) |
-h, --help | Show subcommand help |
3.1 otigen init
Scaffold a new project from a language template.
otigen init <name> --lang <rust|as|go|c> [--type <contract|parachain>] [--dir <path>]
| Arg | Required | Description |
|---|---|---|
<name> | yes | Project name. Used for the contract/parachain identity and the directory. |
--lang | yes | Target language: rust, as (AssemblyScript), go (TinyGo), or c (clang/wasm32). |
--type | no | contract (default) or parachain. Selects the appropriate scaffold. |
--dir | no | Target directory (default: ./<name>). |
Side effects:
- Creates
<dir>/. - Writes
<dir>/otigen.tomlfrom the language template (see §4 for schema). - Writes
<dir>/src/containing a hello-world contract. The Rust scaffold uses the macro substrate (#[pyde::entry]+pyde::declare_storage!()) so authors get typed accessors + a() -> ()ABI shim with zero hand-writtenextern "C"boilerplate. Non-Rust scaffolds (--lang as|go|c) ship the rawextern "C"host-fn pattern — the macro substrate is Rust-only; community SDK authors targeting other languages referenceexamples/counter-{as,go,c}/and theSDK_AUTHOR_GUIDE. - Writes language-specific config (e.g.,
Cargo.tomlfor Rust,package.jsonfor AS,go.modfor Go). - Writes
.gitignoreexcludingtarget/,node_modules/,build/.
Exit codes: 0 on success, 1 if <dir> already exists, 2 if the language is unknown.
3.2 otigen build
Verify + package. By default does not invoke the language compiler — that is the author's responsibility (run cargo build first, etc.). The opt-in --compile flag inverts this: it runs the per-language default build command first, then proceeds with the same verify + package pipeline. Both paths produce byte-identical bundles when the inputs are equivalent — --compile is a UX convenience, not a different build.
otigen build [--release|--debug] [--compile] [--out <path>]
| Flag | Default | Description |
|---|---|---|
--release | (default) | Validate against release-build expectations |
--debug | off | Allow debug-build artifacts (useful for local dev) |
--compile | off | Invoke the per-language build command first. Dispatch table: rust → cargo build --target wasm32-unknown-unknown --release, as → npm install && npm run build, go → tinygo build -target=wasm-unknown -o <output> ., c → make. (Important: TinyGo uses wasm-unknown, not wasi — the wasi target imports wasi_snapshot_preview1.fd_write which the build validator rejects.) Only the default invocation per language; authors with custom build flags continue to compile manually and run otigen build afterwards. After the compiler exits, otigen discovers the actual emit path from each language's native config (Cargo.toml's [package].name for Rust, asconfig.json's targets.release.outFile for AssemblyScript; Go uses our -o flag, C uses the Makefile-declared path) and copies the .wasm to [contract.lang.output] if they differ, with a Reconciling emit path notice. Discovery falls back to [contract.lang.output] on workspace Cargo.toml, missing / malformed configs, or features we don't parse (JSON5 in asconfig). Error codes: ToolchainMissing when the compiler isn't on PATH; resource failure on non-zero compiler exit; CompileOutputMissing when the compiler exited 0 but emitted nowhere we can find. |
--out | ./artifacts/ | Output directory for the deploy bundle |
Pipeline:
- Read
otigen.toml. Validate schema (§4). Validate attribute combinations per HOST_FN_ABI_SPEC §3.5.1. - Locate the compiled
.wasmat the path declared in[contract.lang.output]. - Validate the WASM:
- Well-formed binary (passes
wasmparserround-trip). - Every WASM import declares module
pyde(noenv, nowasi:*). - Every imported function name is in the HOST_FN_ABI_SPEC allowlist (and for non-parachain types, no parachain-only fn imports).
- Every function declared in
[functions.X]has a matching WASM export. - Every WASM export (other than internal helpers) is declared in
[functions.X]. - WASM features used are in the deterministic subset (no threads, no SIMD, etc.).
- Well-formed binary (passes
- Static call-graph view check. For each
viewfunction, build the transitive call graph from its body. If any reachable function importspyde::sstore/sdelete/transfer/emit_event/parachain_storage_write/parachain_storage_delete/parachain_emit_event, reject withBuildRejected: ViewMutatesState(<fn_name>, <mutating_import>). - Build
ContractAbistruct fromotigen.toml:- For each
[functions.X]: extract attributes, compute selector =Blake3(fn_name)[..4], copy access list. - For each
[events.X]: extract field list, computetopic_signature_hash = Blake3(canonical_signature), mark indexed fields. - Compute
state_schema_hash = Blake3(canonical_state_schema_bytes).
- For each
- Borsh-encode the
ContractAbi. - Inject the encoded ABI as a WASM custom section named
pyde.abi, using thewasm-encoderRust crate. The code section is untouched. - Write the bundle to
<out>/<contract_name>.bundle/:contract.wasm(with thepyde.abicustom section embedded)otigen.toml(verbatim copy)abi.json(human-readable mirror of the ABI for tooling)manifest.json(hashes, build timestamp, otigen version, target network)
Exit codes: 0 on success, 1 on validation failure, 2 if the .wasm was not found at the expected path.
3.3 otigen deploy
Sign and submit a deploy transaction.
otigen deploy [--network <name>] [--from <addr-or-keyname>] [--bundle <path>] [--init-arg <hex>]
[--dry-run] [--no-wait] [--password-stdin]
[--rpc-url <URL> --chain-id <N>]
| Flag | Default | Description |
|---|---|---|
--network | from otigen.toml | Target network. |
--from | from otigen.toml [wallet.default_account] | Signing account in the keystore. |
--bundle | ./artifacts/<name>.bundle/ | Path to the deploy bundle. |
--init-arg <HEX> | empty | Hex calldata for the constructor (init). Empty for nullary constructors. |
--dry-run | off | Build + sign the tx but don't submit. Prints the wire bytes for inspection. |
--no-wait | off | Submit and exit without polling for the receipt. |
--password-stdin | off | Read the wallet password from stdin instead of prompting via rpassword. |
--rpc-url <URL> | none | One-shot RPC URL override. Bypasses the project's [network.<name>] for the RPC endpoint. REQUIRES --chain-id (see §3.3.1). |
--chain-id <N> | none | Required when --rpc-url is set; ignored otherwise. The chain id the signer commits to in the canonical tx-hash domain. |
Pipeline:
- Load bundle. Re-validate both the project's
otigen.tomland the bundle's copy ofotigen.tomlagainst the schema — samevalidate()pass thatotigen buildruns. Defense-in-depth: catches hand-edits between build and deploy, bundles produced by a forked toolchain that skipped validation, and bundles built before charset rules existed. The[contract].namefrom the bundle'sotigen.tomlis hashed into the deployed address by the chain, so a malformed name reaching this step would persist on-chain. - Re-validate WASM + ABI consistency (
otigen-abi::validate_allin strict mode — same checks the chain-side validator runs). - Construct a
DeployTx:DeployTx { sender, name, // contract/parachain name wasm_bytes, // the .wasm with embedded pyde.abi contract_type, // Contract or Parachain initial_state_input, // calldata for the constructor (if any) nonce, gas_limit, gas_price, chain_id, // splices in --chain-id when --rpc-url is set } - Compute canonical tx hash. FALCON-sign with the sender's key (prompts for keystore password unless cached or
--password-stdinis set). - Submit via
pyde_sendRawTransaction. Print the tx hash. - Unless
--no-wait: pollpyde_getTransactionReceiptuntil included. The receipt poll timeout is 60 seconds, constant (not CLI-configurable). Report success / revert.
Exit codes: 0 on inclusion + success, 1 on validation failure (including the --rpc-url without --chain-id guard in §3.3.1), 2 on network error, 3 on revert.
3.3.1 --rpc-url + --chain-id (signed-tx RPC override)
The pair is mutually load-bearing: passing one without the other returns InvalidArgs with exit 1. The resolver returns chain_id = 0 on the raw---rpc-url path (a raw URL doesn't advertise a chain id), and signing a tx against chain_id = 0 silently bricks the FALCON signature against the chain's tx-hash domain — the engine rejects the tx but the cost is paid in gas before that revert.
Use case: a CI worker spinning a devnet on a non-default port because 127.0.0.1:9933 is already taken by another instance (multi-validator cluster, parallel test runs). The override lets deploy target that instance without editing the bundle's baked otigen.toml:
otigen deploy --bundle <bundle> \
--rpc-url http://127.0.0.1:29933 \
--chain-id 31337 \
--from devnet-0 \
--password-stdin
Match --chain-id to what pyde_chainId reports on the target RPC. The same pair is required across §3.4 (call — mutating mode), §3.5 (upgrade), §3.6 (pause / unpause / kill), and §3.14's submitting variants — every CLI subcommand that signs a tx.
3.4 otigen call
Invoke a function on a deployed contract. View vs mutating is decided by the presence of --from: without a signing wallet the call runs read-only through pyde_call; with --from <wallet> it submits a TxType::Standard transaction the same way otigen deploy does.
otigen call <target> <function> [ARGS...] [--args <HEX>] [--raw]
[--from <wallet>] [--rpc-url <URL>] [--network <name>]
[--value <quanta>] [--no-wait] [--password-stdin]
| Arg / Flag | Default | Description |
|---|---|---|
<target> | required | Contract name (registered) or 0x-prefixed address. |
<function> | required | Function name as declared in [functions.<fn>]. |
ARGS... | none | Positional, typed per [functions.<fn>].inputs in declaration order. See "Typed positional args" below. Mutually exclusive with --args. |
--args <HEX> | none | Pre-encoded borsh calldata, hex-encoded. Escape hatch when typed args don't fit. Mutually exclusive with positional ARGS. |
--raw | off | Preserve raw hex output for view-call returns. Default behaviour decodes per [functions.<fn>].outputs. |
--from <WALLET> | none (view mode) | Signing account. Presence flips the call to a state-mutating signed tx. |
--rpc-url <URL> | from otigen.toml | One-shot RPC override. View-mode does NOT require --chain-id; mutating-mode does (same contract as §3.3.1). |
--network <NAME> | [network.default] | Network selector from otigen.toml. |
--value <QUANTA> | 0 | Native PYDE attached to a mutating call (quanta = 10⁻⁹ PYDE). |
--no-wait | off | Mutating mode: submit + exit without polling. |
--password-stdin | off | Read wallet password from stdin. |
3.4.1 Typed positional args
ARGS are marshalled per [functions.<fn>].inputs in declaration order:
- Primitives (
u8..u128,i8..i128,bool,address,hash32,bytes,string) — bare values.address-typed inputs accept either a0x-prefixed 64-char hex literal OR a wallet name from the local keystore (devnet-0,alice, …). Wallet names resolve to the keystore entry's deployed address. vec(T)— JSON array literal:'[1,2,3]'(standard borshVec<T>wire shape).- Named struct from
[types.<Name>]— JSON5 object:'{maker:0xaa…,id:1,amount:100}'. Field order in the literal does not matter; the marshaller looks fields up by name. See §4.13. - Named enum from
[types.<Name>]— variant name as a bare string:Pending. v1 enums are unit-only. See §4.13. - Unquoted
0xhex literals of 16+ chars are auto-quoted before JSON5 parse so 32-byte hash / address values don't need surrounding quotes inside struct + array literals.
--args 0x<hex> is the escape hatch when typed args don't fit (e.g. calling a contract without a local otigen.toml, or supplying hand-built calldata). Mutually exclusive with positional ARGS.
3.4.2 Auto-decode of view returns
By default, view returns are decoded per [functions.<fn>].outputs:
- Single output → bare value (e.g.
1000000for auint128). - Multi-output → tuple syntax (e.g.
(true, 1000000)). - Compound shapes (
vec(T),struct(<Name>), enum) → JSON5-style rendering.
--raw preserves the on-wire hex (0x40420f00…) — useful for piping into external decoders or for contracts the CLI doesn't have a schema for.
In --json mode, the call_result event carries return_data as the raw hex and the decoded form in a separate decoded field.
3.4.3 Examples
# Primitive — address from local keystore
otigen call my-token balance_of devnet-0
# Primitive — explicit 0x hex address
otigen call my-token balance_of 0x9b8c7d6e5f4a3b2c...
# vec(u64)
otigen call my-pool echo_vec_u64 '[1,2,3]'
# Struct from [types.Order]
otigen call my-orders echo_order '{id:1,maker:devnet-0,amount:100,paid:true}'
# Enum from [types.Status]
otigen call my-orders echo_status Pending
# Raw hex return for piping
otigen call my-token balance_of devnet-0 --raw
# Escape hatch — hand-built calldata
otigen call my-contract some_fn --args 0x0100000000000000
# State-mutating call (signed tx)
otigen call my-token transfer devnet-1 1000 --from devnet-0 --password-stdin <<< pw
Exit codes: 0 on success (view returned cleanly OR mutating tx included with success), 1 on validation failure (bad arg shape, mutually-exclusive ARGS + --args, --rpc-url without --chain-id in mutating mode), 2 on RPC / network error, 3 on contract revert (mutating mode).
3.5 otigen upgrade
Replace a contract's WASM via the upgrade flow.
otigen upgrade <name-or-address> [--bundle <path> | --wasm <path>] [--from <key>]
[--no-wait] [--password-stdin] [--i-know-engine-rejects]
[--rpc-url <URL> --chain-id <N>]
| Flag | Default | Description |
|---|---|---|
--bundle <PATH> | ./artifacts/<name>.bundle/ | Bundle directory containing the new contract.wasm. Mutually exclusive with --wasm. |
--wasm <PATH> | none | Explicit path to the new .wasm. Overrides --bundle. |
--from <KEY> | from otigen.toml | Signing account (the contract's deployer / admin). |
--no-wait | off | Submit and exit without polling for the receipt. |
--password-stdin | off | Read wallet password from stdin. |
--i-know-engine-rejects | off | Bypass the engine-not-ready gate (see §3.6.1). |
--rpc-url / --chain-id | none | RPC override pair, same contract as §3.3.1. |
For contracts: signs an UpgradeContractTx (TxType::Lifecycle / LifecyclePayload::Upgrade { new_wasm } per §8.3). As of v1 the engine has no TxType::Lifecycle handler — the tx is built and signed correctly by the CLI but refused at the engine. See §3.6.1.
For parachains: requires governance certs collected separately (per PARACHAIN_DESIGN §6.2). otigen upgrade --parachain runs the full vote flow if [parachain.governance.auto_collect] is true; otherwise the author submits the proposal, gathers votes externally, and runs otigen upgrade --finalize <proposal-id> to submit the activation tx.
3.6 otigen pause / otigen unpause / otigen kill
Operational lifecycle.
otigen pause <name-or-address> [--from <key>] [--no-wait] [--password-stdin]
[--i-know-engine-rejects] [--rpc-url <URL> --chain-id <N>]
otigen unpause <name-or-address> [...same flags as pause...]
otigen kill <name-or-address> [...same flags as pause...] [--yes]
| Flag | Default | Description |
|---|---|---|
--from <KEY> | from otigen.toml | Signing account (contract deployer / admin). |
--no-wait | off | Submit and exit without polling for the receipt. |
--password-stdin | off | Read wallet password from stdin. |
--i-know-engine-rejects | off | Bypass the engine-not-ready gate (§3.6.1). |
--rpc-url / --chain-id | none | RPC override pair, same contract as §3.3.1. |
--yes (kill only) | off | Skip the interactive re-type the contract name confirmation. |
pause: signsLifecyclePayload::Pause. Reversible.unpause: signsLifecyclePayload::Unpause.kill: signsLifecyclePayload::Kill. Irreversible; the interactive confirmation prompts for the contract's name to be re-typed verbatim unless--yesis passed.
3.6.1 The engine-not-ready gate
Per §8.2 + §8.3, v1 ships no chain-side TxType::Lifecycle handler. The CLI surface is committed in code so that when engine support lands the wire shape doesn't shift, but submitting any of the four lifecycle subcommands against current mainnet / devnet would revert at the engine with decode CallPayload: Unexpected length of input (the engine treats the Standard-shape tx as a contract call and tries to decode the borsh-encoded LifecyclePayload as a CallPayload { function, calldata }).
To avoid burning gas on a guaranteed-failed tx, the CLI refuses to submit by default. The exact error:
otigen [ERROR] EngineNotReady: `<op>` lifecycle ops are not yet wired
on the chain side (no TxType::Lifecycle handler, no paused/killed
Account fields). Submitting this tx would revert with
`decode CallPayload: Unexpected length of input` and consume gas.
Exit code 1 (VALIDATION_FAILURE).
--i-know-engine-rejects opts past the gate. The CLI then signs and submits the tx normally; the engine rejects it normally. This is for two narrow cases: (1) exercising the CLI signing path against a stub engine in CI / regression testing, (2) developing the engine-side handler against a real wire shape. Mainnet operators must not pass this flag.
The v1 alternative patterns (proxy contracts for upgrade; author-declared paused: bool / killed: bool in [state] for pause/kill) are documented in §8.2 + §8.3.
3.7 otigen inspect
Read contract / parachain state and metadata.
otigen inspect <name-or-address> [--state-field <NAME> | --field <NAME>]
[--at-wave <wave_id>] [--rpc-url <URL>]
| Flag | Default | Description |
|---|---|---|
--state-field <NAME> | none | Substrate-typed storage read. Slot = Poseidon2(self_address ‖ field_name), matching the chain's sstore_scalar / sload_scalar host fns. Decoded per the type token declared in [state].schema. Use this for any contract written with #[pyde::declare_storage] — the canonical path. |
--field <NAME> | none | Legacy raw-storage read for pre-substrate contracts. Slot = Poseidon2(name.as_bytes()) — the convention hand-written examples used before the substrate macros existed. Mutually exclusive with --state-field. |
--at-wave <N> | none | State as of a historical wave. Honored only by archive nodes; v1 toolchain forwards the value but otherwise shows current state. |
--rpc-url <URL> | from otigen.toml | One-shot RPC URL override. Skips otigen.toml network resolution entirely. Unlike the signing subcommands, inspect is read-only and does NOT require --chain-id. |
Default output (no --state-field / --field) — account snapshot per the chain's pyde_getAccount RPC:
Target/Address— the supplied name/address + the resolved 32-byte address.Account type—eoa,contract, orsystem(chain'sAccountTypediscriminant).Balance— current balance in hex quanta.Nonce— next acceptable nonce (nonce_window.base + bitmap.trailing_ones()).Code hash—Poseidon2(runtime_wasm). Zero for EOA / system accounts.Code size— length of deployed bytecode in bytes.State root— Blake3 summary of the contract's storage sub-trie. V1 keeps this all-zero (single global JMT).
With --state-field or --field: focused single-slot read; prints the slot hash + raw bytes + decoded value (per [state].schema for --state-field).
Note. Earlier drafts of this spec listed
version/total_versions/owner/statusfields. None of these exist on the engine'sAccountin v1 — see §8.2/§8.3 for what v1 actually provides and the v2 plan. The v1 lifecycle story is author-declared booleans + proxy upgrades; chain-side support is deferred.
3.8 otigen wallet
Wallet subcommands. <NAME> is positional in every subcommand that takes one (no --name flag).
otigen wallet new [NAME] [--password-stdin] # generate a new FALCON-512 keypair
otigen wallet import [NAME] # interactive: paste pubkey + secret key
otigen wallet import --from-file <PATH> <NAME> # restore a `wallet export` backup
otigen wallet import --from-devnet [--prefix <P>] [--count <N>] [--password-stdin]
# bulk-import 10 deterministic prefunded devnet accounts
otigen wallet list # list every account name + address
otigen wallet show <NAME> # print address + pubkey (no password needed)
otigen wallet delete <NAME> [--yes] # remove an account
otigen wallet password <NAME> # re-encrypt under a new password (TTY only — no --password-stdin)
otigen wallet export <NAME> [--out <PATH>] # write a portable encrypted backup (no password prompt)
otigen wallet sign [NAME] --message <MSG> [--hex] [--password-stdin] # FALCON-512 sign arbitrary bytes (NOT for chain txs)
otigen wallet verify [NAME] --message <MSG> --signature <HEX> [--hex] [--pubkey <HEX>]
# verify a signature; exit 0 = valid, 1 = invalid
NAME under --from-devnet is ignored — that mode generates names <prefix>0..<prefix>N-1 (default devnet-0..devnet-9).
wallet export does NOT prompt for a password: it ciphers the keystore entry as-is and writes the same Argon2id + AES-256-GCM ciphertext under a new filename. The original password still decrypts it.
Note. Earlier drafts of this spec listed
wallet rotate <name>(submitting aKeyRotationTx). That subcommand is not shipped in the current binary — chain-side key rotation lives behind v2 engine work. If you need to rotate an account's encryption password (vs the underlying FALCON key), usewallet password.
Keystore format: see §6.
3.9 otigen console
Interactive REPL against a Pyde node. Foundry-cast shape;
line-edited via rustyline with persisted history at
~/.otigen_console_history and Ctrl-C / Ctrl-D handling that
matches every other shell.
otigen console [--network <name>] [--from <key>] [--password-stdin]
Session-scoped: --network and --from bind once at REPL
startup, every per-command call reuses the same RpcClient.
Wallet unlock is lazy — views never prompt; the first tx asks
for the password once (or reads it from stdin with
--password-stdin for CI / scripted flows) and the unlocked
signer is cached for the rest of the session.
REPL commands (shipping today):
help/?— list the catalogbalance <addr>— query native PYDE balancenonce <addr>— query next-acceptable noncecall <addr> <fn> [hex]— invoke aviewfunction (free, off-chain viapyde_call)tx <addr> <fn> [hex] [--value <decimal>]— sign + submit + receipt pollstate <addr> <field>— substrate-typed scalar storage read (uses--state-fieldderivation)events <addr> [--from N] [--to N] [--limit N]— pullpyde_getLogswith optional wave boundssubscribe <filter>— WebSocket subscription to live eventsinspect <addr>— account snapshot (mirrorsotigen inspect)exit/quit— leave the console
Address inputs accept either a 0x-prefixed 32-byte hex address
or a registered name resolved via pyde_resolveName.
Deferred (follow-up PRs):
- ABI-aware calldata typing (Foundry's
cast send --json-abishape) — currently calldata is supplied as raw hex.
3.10 otigen verify
Verify that a published contract's bundled artifact matches its on-chain deployment.
otigen verify <name-or-address> [--bundle <path>] [--strict-toolchain]
[--explorer <URL>] [--api-key-env <VAR> | --api-key-stdin]
| Flag | Default | Description |
|---|---|---|
--bundle <PATH> | ./artifacts/<name>.bundle/ | Local bundle directory to compare against. |
--strict-toolchain | off | Also compare the toolchain version pin in manifest.json against the running rustc / TinyGo / asc / clang. Mismatch fails verify even when bytes match — useful for reproducing audited builds at the exact toolchain. |
--explorer <URL> | none | Submit the bundle to an external verifying explorer. Posts a multipart form (contract.wasm, manifest.json, metadata.json) to <URL>/api/v1/contracts/<addr>/verify. |
--api-key-env <VAR> | none | Read the explorer's bearer token from the named environment variable. |
--api-key-stdin | off | Read the explorer's bearer token from stdin. Mutually exclusive with --api-key-env. |
Compares the local bundle's WASM bytes against the chain's pyde_getContractCode(addr) response. The CLI re-derives the Blake3 hash of the local contract.wasm and compares both byte length and hash.
Exit codes: 0 on match, 1 on mismatch (with a diff summary including size delta and first-differing-byte offset). 2 on network or filesystem error.
The --explorer path uploads independently of the local-vs-chain check. The CLI redacts the API key when echoing the endpoint in human-readable output.
3.11 otigen test
Run contract behaviour tests declared in TOML against the built .wasm.
otigen test [--dry-run] [--filter <pattern>] [--bundle <path>] [--watch]
[--no-engine] [--no-compile] [--json] [-v|-vv]
| Flag | Default | Description |
|---|---|---|
--dry-run | off | Parse + resolve every .test.toml and print the resolved hashes (account names → addresses, storage field paths → slots) without executing the WASM. Useful for validating the canonical derivation lines up with the contract's slot-derivation logic. |
--filter <PATTERN> | none | Substring filter on test names. Repeating the flag is last-wins (handled at clap level for v1). |
--bundle <PATH> | ./artifacts/<name>.bundle/ | Bundle whose contract.wasm is executed. |
--watch | off | Re-run on every file change. Watches the project directory recursively; ignores target/, artifacts/, .git/, node_modules/, build/, dist/. Debounces within 300 ms. Ctrl-C to exit. |
--no-engine | off | Opt OUT of the engine path and fall back to the legacy in-process mock host-fn surface. See "Runtime selection" below. |
--no-compile | off | Skip the per-language compiler (cargo / asc / tinygo / clang) and run the suite against the existing .wasm as-is. Default behaviour invokes the compiler first so a single otigen test reflects the live contract source. |
--json (global) | off | Emit NDJSON test events (test_suite_start / test_start / test_pass / test_fail / test_suite_done) instead of plain text. |
-v (global) | off | INFO logs from the runner. |
-vv (global) | off | DEBUG logs (host-fn calls, slot derivations). |
Note. Earlier drafts of this spec listed
--no-color,--show-output, and a four-level Foundry-style-vvvvverbosity ladder (per-call traces + storage diffs). The current binary rejects--no-color/--show-outputand uses standard clap-vcounting only. Per-call expectations + storage diffs are declared in[tests.expect]/expect.*in the test TOML; failures print the expected-vs-actual. A Foundry-shape trace renderer is tracked for a follow-up; until then declarative assertions are the surface.
Runtime selection. otigen test runs through pyde-engine-wasm-exec::WasmExecutor by default — the same execution code path mainnet uses. Per the project principle "same crypto / same VM everywhere across mainnet / testnet / devnet" the engine path is the source of truth; the legacy in-process mock surface still ships behind --no-engine for two cases:
- Parachain contracts (
contract.type = "parachain") — parachain host fns live behind engine v2; until thenotigen testagainst a parachain bundle requires--no-engineand gets the legacy mock surface withparachain_*mocks (see "Legacy mock surface" below).otigen test --engineagainst a parachain pre-flights withParachainEngineUnsupportedpointing at--no-engine. - Bisection / debugging — running both paths against the same test and comparing surfaces which side is misbehaving.
Discovery order:
tests/*.test.toml(canonical)tests/*.toml./contract.test.toml(single-file projects)
Each file's [[tests]] array contributes to the total count. Tests run sequentially; each starts from a fresh state store backed by a tempdir — no state leaks between cases. The engine path builds a fresh EngineRunner per test case; the legacy path builds a fresh in-process TestEnv per case.
Per-test pipeline (engine path):
- Apply
[cheats](and per-test[tests.cheats]overrides). - Resolve account names → 32-byte Blake3 addresses; resolve storage field names → slot hashes per the contract's
[state]schema (the chain derivesBlake3(self_address || field_name || keys...)host-side for the typed-storage path; Poseidon2 host-side for the legacy raw-host-fn path). - Pre-populate storage from
[tests.setup].storage+ balances from[tests.setup].balances. - Record start time.
- For each
[[tests.calls]]entry: marshal typed args (address/uint128/int128/bytes32/bytes/ primitive ints) into wasm linear memory + params, invoke the WASM export through the engine'sWasmExecutor::execute_call, capture the return value + emitted events + revert reason. On trap, the per-callTxOverlaydiscards (so a reverting call mid-test doesn't roll back state from earlier successful calls — matching mainnet semantics). Check per-callexpect. - After the call sequence, check
[tests.expect](final-state assertions: storage values, balances, event totals). - Record end time; compute
duration_ms. - Emit pass / fail (with
duration_msincluded in the NDJSON event under--json).
Engine path host-fn surface. The engine path links the real pyde::* ABI — same host fns mainnet runs (HOST_FN_ABI_SPEC §7). The runner stubs nothing beyond the test-only debug_log (printf-style; not registered chain-side, see "Test-only host fns" below). Authors writing contracts that hit tx_hash, calldata_copy, consume_gas, cross_call_static, return, hash_keccak256, beacon_get, or origin get them at chain-fidelity behaviour.
Legacy mock surface (--no-engine only). The legacy path runs each contract in an in-process wasmtime instance wired to test-runner mocks. Useful for parachain contracts (whose chain runtime ships in v2) and for runner-side debugging. Mocked host fns:
- Storage (variable-length):
sload,sstore,sdelete - Account & balance:
balance,transfer - Execution context:
caller,self_address,wave_id,wave_timestamp,chain_id - Transaction context:
tx_value - Events + halt:
emit_event,revert - Hashing:
hash_blake3,hash_poseidon2,hash_keccak256 - Post-quantum crypto:
falcon_verify— real verification via the runner's bundledpyde-crypto; pairs with the@sig:NAME:args.IDXDSL (seeOTIGEN_TEST_SPEC §6) - Cross-contract:
cross_call,delegate_call— multi-contract topology declared via[[contracts]]; each secondary instance gets its own Store + storage namespace - Parachain §8 (when
[contract].type = "parachain"):parachain_id,parachain_version,parachain_storage_read,parachain_storage_write,parachain_storage_delete,parachain_emit_event
Test-only host fns (both paths). debug_log(msg_ptr, len) -> () — printf-style; writes [debug] <fn_name>: <msg> to stderr and captures into the test report's debug_logs. Not registered chain-side; otigen build and otigen deploy reject contracts that import it (HOST_FN_ABI_SPEC §9.1).
Exit codes: 0 all-pass; 1 any failure; 2 resource failure (test file unreadable, bundle missing); 4 schema error (malformed TOML, reference to undeclared [state] field, parachain contract attempted on engine path).
Gas tracking. Both paths enable wasmtime's consume_fuel(true) and seed each call with the test's cheats.gas_limit (default 1,000,000,000 fuel). Per-call gas usage is fuel_cap - remaining_fuel after the call returns. Total gas per test is the sum across calls.
- Reported in the NDJSON
test_pass/test_failevents asgas_used. - Surfaced at
-vand above in the plain-text output. - Optionally asserted via
expect.gas(exact) orexpect.gas_max(upper bound) per call. SeeOTIGEN_TEST_SPEC §4.5.
Note: the runner's fuel units correlate to but are not bit-identical with on-chain Pyde gas. Foundry has the same caveat — gas reports under forge test are estimates, not chain billing.
The full TOML schema, name resolution rules, cheatcode catalogue, host-function behaviour, and limitations are documented in OTIGEN_TEST_SPEC.md. That spec is authoritative.
3.12 otigen new
Scaffold a project by cloning a canonical example from the pyde-net/otigen example catalogue. Where otigen init writes a minimal hello-world, otigen new produces a fully-working contract with a passing TOML test suite — the fastest path from zero to a green otigen test run.
otigen new <name> --from <template> [--dir <path>]
otigen new --list
| Arg | Required | Description |
|---|---|---|
<name> | yes (unless --list) | Project name. Lowercase + hyphens (ENS-style, 1–32 chars). Used for the contract identity and the directory. |
--from | yes (unless --list) | Template to clone. Run otigen new --list for the catalogue. |
--list | no | List available templates and exit. Mutually exclusive with <name>, --from, and --dir. |
--dir | no | Target directory (default: ./<name>). |
Canonical templates exposed by otigen new --list — the binary's template registry. Every entry uses the #[pyde::entry] + pyde::declare_storage!() macro substrate per HOST_FN_ABI_SPEC §3.5.2 (void-void entries) and builds + tests clean.
| Template | Status | Highlights |
|---|---|---|
counter | ✅ builds + tests | Minimum viable contract. Single u64 slot via typed storage::counter() accessor. Equivalent of otigen init --lang rust. |
erc20-token | ✅ builds + tests | Canonical real-contract reference. Scalar + 1-key + 2-key map storage shapes, indexed-field event encoding, typed-arg calldata. |
erc721-token | ✅ builds + tests | ERC721-shape NFT. Per-token ownership + balance_of + single-spender approval cleared atomically on transfer_from. |
upgradeable-proxy | ⚠️ builds, shipped tests broken | delegate_call-based upgrade pattern. Admin-controlled implementation slot. The shipped tests/contract.test.toml references entrypoint names that no longer match the source — tests fail 0/7 until the fixture is regenerated. The contract itself deploys + delegates fine. |
dao-governance | ✅ builds + tests | FALCON-signed votes + time phases + hash_blake3-committed execution. The most-composed v1 reference. |
simple-multisig | ✅ builds + tests | 3-signer FALCON-512 multisig via pyde::raw::falcon_verify + signer-ID lookup on typed-storage maps; Hash32 (bytes32 alias) keys + values. |
merkle-claim-airdrop | ✅ builds + tests | Merkle-tree airdrop claim via pyde::hash::blake3; Vec<u8>-typed proof argument decoded by the macro substrate. |
vesting | ✅ builds + tests | Linear vesting with cliff. Time-locked allocation via pyde::ctx::wave_timestamp + scalar uint128 / uint64 typed storage. |
Note. Earlier drafts of this spec listed ~17 templates including alt-language counter ports (
counter-go/counter-as/counter-c),payment-channel,multisig-wallet,nft-marketplace,borsh-coverage,struct-storage,storage-stress,escrow, andprofile-registry. Those reference contracts live on disk underotigen/examples/and can be cloned viagit, but they are not inotigen new's scaffold registry today. Promoting them requires §3.5.2-compliant entry shapes + a working test suite; track via the examples guide chapter.
Side effects:
- Creates
<dir>/and copies every file from the template into it. - Rewrites identity fields to
<name>inotigen.toml([contract].name),Cargo.toml/package.json/go.mod(per-language idiom), and theMakefile's display strings. The rewriter is word-boundary-safe — scaffoldingvesting-appfromvestingno longer produces the malformedvesting_app-appof earlier toolchain versions. - Preserves every other file byte-for-byte —
src/,tests/, the per-templateREADME.md, the build config — socd <name> && otigen testproduces an identical result to running the template in-tree.
Exit codes: 0 on success, 1 if <dir> already exists or the template is unknown (UnknownTemplate(name) error → run with --list to see the catalogue).
3.13 otigen devnet
Run a local devnet. The chain runtime is embedded in the otigen binary — no separate pyde download or path resolution. The devnet's ValidatorRuntime builds on a tempdir-backed StateStore, applies the genesis prefund via a ValidatorPreRunHook, binds the JSON-RPC server, and runs until Ctrl-C. State is wiped on shutdown.
otigen devnet [--fork <FILE_OR_URL>] [--rpc-listen <ADDR>]
[--prefund-count <N>] [--prefund-amount <QUANTA>]
[--chain-id <ID>] [--tick-ms <MS>]
| Flag | Default | Description |
|---|---|---|
--fork <FILE_OR_URL> | none | Fork the devnet's state from an existing snapshot. Accepts either a local borsh snapshot file (./snapshot.bin, produced by the engine's Snapshotter::build) OR an HTTP(S) URL pointing at a running validator's pyde_getSnapshot RPC endpoint. Mutually exclusive with --prefund-*. Known limitation: forking a live devnet currently fails the state-root re-derivation check; the file path is the more reliable mode today. |
--rpc-listen <ADDR> | none (banner-only) | JSON-RPC server bind address. Pass 127.0.0.1:9933 to enable RPC so deploy / call / console have a target. |
--prefund-count <N> | 10 | Number of pre-funded accounts the banner enumerates. Each is derived deterministically from Blake3("pyde-devnet-v1/" || i) and the embedded prefund hook writes them at wave_id = 0 before the validator main loop starts. Mutually exclusive with --fork. |
--prefund-amount <QUANTA> | 10_000_000_000 | Per-account genesis balance in quanta. Mutually exclusive with --fork. |
--chain-id <ID> | 31337 | Chain id this devnet signs against. The canonical "dev chain, don't replay" sentinel. |
--tick-ms <MS> | 1000 | Idle-wave tick interval in milliseconds. Even with no pending txs the devnet commits an empty wave every --tick-ms so wave_id advances. |
Note. Earlier drafts of this spec listed
--engine-bin <PATH>(withPYDE_BINenv fallback). That flag is not shipped — the chain runtime is no longer a separate process. Validator + full-node roles still ship via the engine's ownpydebinary; those are operator concerns, not author concerns.
stdin/stdout/stderr inherit from the parent so the embedded runtime's startup banner + any RUST_LOG=info traces flow straight through; Ctrl-C triggers graceful shutdown via the validator's signal handler.
Mutual-exclusion check between --fork and the --prefund-* flags fires up-front so authors get a fast clear error instead of mid-startup runtime rejection.
Exit codes: 0 on graceful shutdown, 1 on validation failure (conflicting flags), 2 on runtime / chain-side error.
3.14 otigen check
Validate the project without packaging. Same checks as otigen build steps 1–7 (read + schema-validate otigen.toml, locate .wasm, every WASM-level validator, ABI build) minus the bundle write. Intended for pre-commit hooks, IDE integrations, and tight iteration loops where the bundle write is wasted I/O.
otigen check [--compile]
| Flag | Default | Description |
|---|---|---|
--compile | off | Run the per-language build command first (same dispatch table as otigen build --compile). |
Exit codes: 0 on clean validation, 1 on validation failure (with per-violation diagnostics on stderr), 2 if the .wasm was not found at the declared [contract.lang.output] path.
Coverage parity with otigen build: any contract that passes otigen check will pass otigen build's validators identically (steps 8+ are I/O-only). Likewise any contract that fails otigen build validation fails otigen check with the same diagnostic. The two commands share the validation core; check is build with the writer disabled.
3.15 otigen validator
Read-only validator-introspection over the engine's
pyde_getValidator + pyde_getOperatorValidators RPCs.
Backs explorers, off-chain indexers, and operator dashboards
without scripting raw JSON-RPC. Registration / stake /
unbond / unjail / key-rotation are out of scope — those tx
flows live on the pyde stake CLI shipped with the engine
binary.
otigen validator show <address>
otigen validator by-operator <operator-address>
| Subcommand | Description |
|---|---|
show <address> | Fetch one validator's full chain-side record: operator + pubkey + stake (u128 hex) + status (active / unbonding / exited / jailed) + unbond_at_wave (only when unbonding) + jail_until_wave (only when jailed) + last_claimed_rps (u128 hex reward checkpoint) + uptime_bps (basis points). |
by-operator <addr> | List every validator the queried operator runs, in registration-order. Empty list for unknown operators. |
Both subcommands accept a 32-byte 0x-prefixed hex address.
Address validation is local — typos don't burn an RPC round
trip.
Wire shapes match the engine handlers landed in
pyde-net/engine#255:
pyde_getValidator(addr) → ValidatorRecord | nullpyde_getOperatorValidators(addr) → [address]
--json (per §10.2) emits one
NDJSON event per invocation (validator_show or
validator_by_operator).
Exit codes:
| Code | Cause |
|---|---|
0 | Success: show rendered a record; by-operator rendered the (possibly empty) list. |
1 | show only — the queried address is not a registered validator (engine returned null). Diagnostic on stderr: NotAValidator: 0x… is not registered as a validator. Scripts can branch on this without parsing stdout. |
2 | Validation failure: malformed address, missing [network.<name>], RPC client construction failed. |
4 | RPC transport / decode failure (chain unreachable, response shape didn't decode). |
4. otigen.toml schema
The canonical config file. Lives at the project root.
4.1 Top-level tables
[contract]
name = "my-token" # required; lowercase + hyphens (ENS-style; see §4.2)
version = "1.0.0" # required; semver
description = "Example token" # optional
type = "contract" # "contract" (default) or "parachain"
[contract.lang]
language = "rust" # required; rust | as | go | c
output = "target/wasm32-unknown-unknown/release/my_token.wasm" # required; Rust crate name uses snake_case (cargo convention), so the .wasm filename uses underscores even though the Pyde contract name uses hyphens
[contract.lang.toolchain]
rust_channel = "stable" # for rust
rust_toolchain = "1.93" # pinned toolchain (matches the workspace floor; was 1.87 pre-wasmtime-45)
asc_version = "0.28.0" # for AS
tinygo_version = "0.41.0" # for go
clang_version = "18" # for c
[deploy]
gas_limit = 10_000_000 # default per-deploy gas budget
gas_price = "auto" # "auto" = use current base_fee; or fixed quanta
owner_deposit = 1000 # PYDE locked at deploy time (parachain only)
[wallet]
default_keystore = "~/.pyde/keystore.json"
default_account = "deployer" # name of the keystore entry to use by default
[network.default]
name = "testnet"
[network.mainnet]
rpc_url = "https://rpc.pyde.network"
chain_id = 1
explorer_url = "https://explorer.pyde.network"
[network.testnet]
rpc_url = "https://rpc-testnet.pyde.network"
chain_id = 2
explorer_url = "https://explorer-testnet.pyde.network"
[network.devnet]
rpc_url = "http://localhost:9933"
chain_id = 31337
[state]
# State schema; each entry declares a top-level state field name and its type.
schema = [
{ name = "owner", type = "address" },
{ name = "total_supply", type = "uint128" },
{ name = "balances", type = "mapping(address -> uint128)" },
]
[functions.transfer]
attributes = ["entry", "payable"]
inputs = ["address", "uint128"]
outputs = ["bool"]
access_list = [
"balances[caller()]", # informational; runtime computes hashes
"balances[args.0]",
]
[functions.balance_of]
attributes = ["entry", "view"]
inputs = ["address"]
outputs = ["uint128"]
access_list = ["balances[args.0]"]
[functions.init]
attributes = ["constructor"]
inputs = ["uint128"]
[events.Transfer]
signature = "Transfer(address,address,uint128)"
fields = [
{ name = "from", type = "address", indexed = true },
{ name = "to", type = "address", indexed = true },
{ name = "amount", type = "uint128" },
]
[events.Approval]
signature = "Approval(address,address,uint128)"
fields = [
{ name = "owner", type = "address", indexed = true },
{ name = "spender", type = "address", indexed = true },
{ name = "amount", type = "uint128" },
]
4.2 [contract] keys
| Key | Type | Required | Default | Validation |
|---|---|---|---|---|
name | string | ✅ | — | 1-32 chars, lowercase ASCII + digits + -, no leading / trailing / consecutive dashes. Matches ENS-style naming (see Chapter 11). This is the chain-level identifier that resolves to the deployed address; if [metadata].name is also set, the two must use the same charset (see §4.12). |
version | string | ✅ | — | semver |
description | string | ❌ | empty | ≤ 200 chars |
type | enum | ❌ | "contract" | "contract" or "parachain" |
4.3 [contract.lang] keys
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
language | enum | ✅ | — | "rust", "as", "go", "c" |
output | path | ✅ | — | Path (relative to project root) where the language compiler writes the .wasm |
The [contract.lang.toolchain] subtable holds language-specific version pins. otigen build does not invoke the compiler — it only validates that the output .wasm exists. But it records the declared toolchain in the bundle manifest for reproducibility.
4.4 [functions.<name>] keys
| Key | Type | Required | Default | Validation |
|---|---|---|---|---|
attributes | array of strings | ✅ | — | Any subset of view, payable, reentrant, sponsored, constructor, fallback, receive, entry. Subject to compatibility rules per HOST_FN_ABI_SPEC §3.5.1 |
inputs | array of strings | ❌ | [] | Parameter types in declaration order |
outputs | array of strings | ❌ | [] | Return types in declaration order |
access_list | array of strings | ❌ | [] | Optional prefetch hint — pairs of (address, slot) patterns the chain may use to warm cache before Block-STM workers start. Not a scheduling primitive; v1 execution is uniform Block-STM regardless of declared access list. Runtime enforcement of declared scope (rejecting out-of-list reads) is a v2 hardening. |
inputs / outputs accept the storage type vocabulary in §4.6 (uint128, address, bool, vec(uint64), …) plus the bare name of any custom type declared in [types.<Name>] (§4.13). For example, inputs = ["Order", "uint128"] references the struct declared in [types.Order]. The asymmetry with [state].schema (which wraps custom types as struct(<Name>)) is intentional — see §4.13.
A function declared in [functions.X] must have a matching WASM export named X. The reverse must also hold (no orphan exports), unless the export name starts with _ (internal helper convention).
4.5 [events.<name>] keys
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
signature | string | ✅ | — | Canonical signature string (Solidity-style), e.g. "Transfer(address,address,uint128)". Must match the field types in declaration order. |
fields | array of tables | ✅ | — | Field metadata (name, type, indexed flag). See HOST_FN_ABI_SPEC §14.1. |
Each field entry:
| Key | Type | Required | Default |
|---|---|---|---|
name | string | ✅ | — |
type | string | ✅ | — |
indexed | bool | ❌ | false |
Rules (validated at otigen build):
- Up to 3 fields can be
indexed(so total topics, includingtopic[0]= signature hash, ≤ 4 — matches EVM LOG4). - The
signaturestring must, when parsed, yield exactly the field types in order.otigen buildcross-checks. - Event names are unique within a contract.
4.6 [state] table
Declares the contract's storage schema. Embedded in the bundle and used for type-safe inspection (otigen inspect --field), explorer UI rendering, the state_schema_hash value in the deployed ABI, AND — for Rust contracts on the macro substrate — by pyde::declare_storage!() at compile time to generate typed accessors. Non-Rust contracts call the chain's typed-storage host fns (sstore_scalar / sload_scalar / sstore_map1…map3) directly; the chain derives the slot internally as Blake3(self_address || field_name || keys...).
schema is an ordered array of { name, type, ... } entries.
Field type vocabulary:
| Token | Width | Notes |
|---|---|---|
u8 / u16 / u32 / u64 / u128 | 1 / 2 / 4 / 8 / 16 | Little-endian. Aliases uint8…uint128 accepted. |
i8 / i16 / i32 / i64 / i128 | 1 / 2 / 4 / 8 / 16 | Two's-complement LE. Aliases int8…int128 accepted. |
bool | 1 | 0 = false, anything non-zero = true. |
address / hash32 | 32 | Raw 32-byte array. bytes32 is an alias for hash32 (Solidity migration ergonomics). |
bytes | variable | u32-len-prefix + bytes. |
string | variable | u32-len-prefix + UTF-8 bytes. |
vec(<inner>) | variable | u32-len-prefix + N × fixed-width inner. Inner must be fixed-width (u8..u128, i8..i128, bool, address, hash32, bytes32) — vec(bytes) / vec(string) / vec(<Custom>) / vec(vec(...)) rejected at parse time. Workaround for arrays-of-struct: use the indexed-map pattern map<u64, struct(<Name>)> (same I/O shape from the contract's perspective; see §4.13). |
struct(<Name>) | variable | Borsh round-trip. Author declares #[derive(BorshSerialize, BorshDeserialize)] on <Name> and the struct in [types.<Name>] (§4.13); the macro emits typed accessors that borsh-encode/decode through the chain's variable-length storage host fns. Chain-side maps to ScalarType::Bytes. |
Map shape — two equivalent surfaces. Either form may be used; the canonical-form keys/value path is the underlying representation and the sugar form is lowered to it at build time.
Canonical form (type = "map" + explicit keys + value):
{ name = "balances", type = "map", keys = ["address"], value = "uint128" }
{ name = "allowances", type = "map", keys = ["address", "address"], value = "uint128" }
Solidity-style sugar (mapping(K => V) / Pyde-style mapping(K -> V)):
{ name = "balances", type = "mapping(address => uint128)" }
{ name = "allowances", type = "mapping(address => mapping(address => uint128))" }
The parser accepts both => (Solidity) and -> (Pyde-arrow / the convention some hand-authored templates carry) as the key/value separator, plus the multi-key flat form mapping(K1, K2 => V). Nested mapping(K => mapping(K2 => V)) recursively flattens to the canonical multi-key form. Both forms produce identical FieldKind::Map { keys, value } post-build.
Map keys: up to 3, each a fixed-width scalar (primitives / address / hash32) or a variable-length scalar (bytes / string). vec(...) and struct(...) keys are rejected up-front to avoid slot collisions on variable-length encodings. Sugar forms exceeding the 3-key cap reject with a clear "exceeds engine's 3-key host fn surface; compose two scalars into a single bytes key" diagnostic.
Map values: any scalar type from the vocabulary above, including struct(<Name>). Mixing forms in the same field (sugar type AND explicit keys/value) is rejected with "pick one form, not both".
4.7 [deploy] table
| Key | Type | Default | Notes |
|---|---|---|---|
gas_limit | u64 | 10_000_000 | Default gas budget for deploy/upgrade txs |
gas_price | string or u128 | "auto" | "auto" reads chain's current base_fee; explicit value is in quanta per gas |
owner_deposit | u128 | 0 | PYDE locked at deploy (parachain only; refunded on kill) |
4.8 [wallet] table
| Key | Type | Default |
|---|---|---|
default_keystore | path | ~/.pyde/keystore.json |
default_account | string | — |
4.9 [network.X] tables
Multiple networks can be declared. [network.default] names which is used when --network is not specified.
| Key | Type | Required | Notes |
|---|---|---|---|
rpc_url | URL | ✅ | JSON-RPC endpoint |
chain_id | u64 | ✅ | Per the HOST_FN_ABI_SPEC chain_id table |
explorer_url | URL | ❌ | For convenient link generation in console output |
[network.default] has only a name field that selects one of the other [network.*] tables as the default.
4.10 [parachain] table (parachain only)
For type = "parachain":
[parachain]
consensus_preset = "simple_bft" # or "threshold" or "optimistic"
min_validators = 7
quorum_threshold = "2/3"
[parachain.governance]
voting_period_days = 3
proposal_cooldown_days = 30
auto_collect = false # if true, `otigen upgrade` runs the full vote flow
[parachain.slashing]
preset = "standard" # minimal / standard / strict
See PARACHAIN_DESIGN for the semantics of each preset.
4.11 [paths] table (Foundry-style project layout overrides)
Optional. Every key has a sensible default, so the table is only needed when an author's project tree diverges from the conventional layout. Declared keys override individually — undeclared keys keep their default.
[paths]
src = "src" # language source root
tests = "tests" # `.test.toml` discovery root for `otigen test`
target = "target" # language compiler intermediate output
artifacts = "artifacts" # `otigen build` bundle output root
cache = ".otigen/cache" # reserved for future module / manifest cache
| Key | Default | Used by |
|---|---|---|
src | "src" | otigen check, reproducibility tooling |
tests | "tests" | otigen test (discovers <tests>/*.test.toml) |
target | "target" | make clean in scaffolded Makefile; reproducibility tooling |
artifacts | "artifacts" | otigen build (writes <artifacts>/<contract.name>.bundle/) |
cache | ".otigen/cache" | reserved for v1.1+ (module cache + manifest replay) |
Foundry parity. Authors moving Solidity projects to Pyde recognise the shape from foundry.toml's [profile.default] src / out / libs / test / cache_path. The defaults assume the conventional layout (everything where a cargo new would put it); the table is purely for overrides.
4.12 [metadata] table (contract-level display info)
Optional. Every field is optional and validated at otigen build so a bundle that builds clean is also accepted by the explorer's verify endpoint without surprise. The whole section can be omitted — defaults are "no metadata declared." Lives bundle-side only; the deployed WASM never carries these bytes.
[metadata]
name = "pyde-usd" # display name, ENS-style charset
description = "Pyde's native stable."
website = "https://pyde.network"
logo_url = "https://pyde.network/usd.svg"
repository_url = "https://github.com/pyde-net/pyde-usd"
documentation_url = "https://docs.pyde.network/usd"
license = "Apache-2.0"
authors = ["Pyde Network"]
tags = ["defi", "stablecoin"]
declared_category = "token"
twitter = "pydenet"
github = "pyde-net"
telegram = "pydenet"
discord = "pyde"
| Key | Type | Validation |
|---|---|---|
name | string | 1-64 chars, lowercase ASCII + digits + -, must start with a letter or digit. Same charset as [contract].name. Dots specifically reserved — the explorer composes the display form <name>.<category> itself (e.g. pyde.oracle, usdt.token), so user-supplied dots collide with the join character. No uppercase, no underscores, no spaces, no symbols, no emoji. |
description | string | ≤ 280 chars on the explorer surface, ≤ 1000 at otigen build (the explorer cap is stricter; bundles that pass otigen but exceed 280 chars are rejected at verify). Free text — emoji are fine here. |
website / logo_url / repository_url / documentation_url | string | ≤ 256 chars, must start with https:// or ipfs://. http:// is rejected (no non-TLS links on verified records), as are javascript: / data: / file: (XSS / phishing vectors). |
license | string | Must be a known SPDX identifier (MIT, Apache-2.0, GPL-3.0-only, BUSL-1.1, …). The accepted list lives in OTIGEN_BINARY_SPEC §4.12.1 (TODO) and the validate_contract_metadata source. |
authors | array of strings | ≤ 5 entries, each ≤ 64 chars. |
tags | array of strings | ≤ 4 entries, each ≤ 32 chars; each must be slug-shaped ([a-z0-9-]+). |
declared_category | string | Reserved for the category enum (token / dex / nft / dao / oracle / bridge / multisig / staking / proxy / escrow / app — final list pending). The explorer composes the display form <name>.<category> from this field. |
twitter / telegram / discord | string | Handle only, no @ prefix, no URL. ≤ 32 chars. |
github | string | Handle or org/repo shape, ≤ 64 chars. |
Two caps are intentionally stricter on the explorer side (280 vs 1000 for description; 4 vs 32 for tags; 5 vs 32 for authors). The explorer ones are the binding limit — they're what the verify endpoint actually enforces and what the verified-card UI is sized for. Plan to converge otigen-toml down to the explorer values in a follow-up.
[contract].name and [metadata].name are independent fields but must share a charset (lowercase ENS-style) so a bundle that builds also verifies. The chain-level identifier ([contract].name) is what derives the deployed address; [metadata].name is purely display.
4.13 [types.<Name>] table
Declares contract-local named types — structs and unit-variant enums — so they can be referenced by bare name in [functions.<fn>].inputs / outputs and via the struct(<Name>) wrapper in [state].schema. Lets contracts pass typed records across the chain ABI without falling back to opaque bytes.
Two shapes, chosen by which key is present.
Struct — fields = [{ name = "...", type = "..." }, ...]:
[types.Order]
fields = [
{ name = "id", type = "uint64" },
{ name = "maker", type = "address" },
{ name = "amount", type = "uint128" },
{ name = "paid", type = "bool" },
]
Field declaration order is wire-load-bearing — borsh keys positional offsets. Reordering breaks compatibility for any contract previously deployed against the old order.
Enum (unit-variant only) — variants = [{ name = "..." }, ...]:
[types.Status]
variants = [
{ name = "Pending" },
{ name = "Active" },
{ name = "Cancelled" },
]
Variant declaration order is the u8 tag (0-based: Pending = 0, Active = 1, Cancelled = 2). Reordering breaks the wire. v1 supports unit-variant enums only — no data-carrying variants. Rationale: cross-language portability. Rust / C++ / Zig have native sum types, but Go / TypeScript / Python need per-variant boilerplate that the bare-tag enum sidesteps. Data-carrying variants are tracked for a v2 follow-up.
Referencing custom types
The asymmetry is intentional:
| Where | Form | Example |
|---|---|---|
[functions.<fn>].inputs | bare name | inputs = ["Order"] |
[functions.<fn>].outputs | bare name | outputs = ["Status"] |
[state].schema | struct(<Name>) / vec(<Name>) wrapper | { name = "current_order", type = "struct(Order)" } |
Function dispatch reads bare names directly from the ABI; the storage macro substrate needs the wrapper to disambiguate a custom type from a primitive type-token name.
Storage vec(T) constraint
Stored arrays must hold a fixed-width inner type: u8..u128, i8..i128, bool, address, hash32, bytes32. Variable-width inners (string, bytes, vec(...), struct(<Name>)) are rejected at parse time — slot derivation collides on variable-width offsets.
Workaround for stored arrays-of-struct (or any non-fixed-width vec element): use an indexed-map pattern.
[state]
schema = [
{ name = "order_count", type = "uint64" },
{ name = "orders", type = "map", keys = ["uint64"], value = "struct(Order)" },
]
orders[i] for i in 0..order_count is the same I/O shape as a vec(struct(Order)) from the contract author's perspective — same read/write calls, no surprise about iteration cost.
Rust contract requirement
Every custom type referenced from [types.<Name>] must carry #[derive(BorshSerialize, BorshDeserialize)] on the Rust side. Without the derives, the macro substrate (#[pyde::entry] arg-decode + pyde::declare_storage!() typed storage accessors) fails to compile — the generated code calls borsh's try_from_slice / serialize on these types.
#![allow(unused)] fn main() { use borsh::{BorshSerialize, BorshDeserialize}; #[derive(BorshSerialize, BorshDeserialize)] pub struct Order { pub id: u64, pub maker: pyde::Address, pub amount: u128, pub paid: bool, } #[derive(BorshSerialize, BorshDeserialize)] pub enum Status { Pending, Active, Cancelled, } }
Non-Rust contracts marshal the wire bytes manually per their language's borsh library (see examples/counter-{go,as,c} for the per-language convention).
5. Per-language build pipeline
otigen does not invoke the language compiler. The author runs the language's own build command first (e.g., cargo build --target wasm32-unknown-unknown --release); otigen build then picks up the resulting .wasm and post-processes it.
This separation keeps otigen simple: it doesn't need to track language toolchain versions, manage compiler flags, or replicate package-manager behavior. The language ecosystem owns the build; otigen owns the chain-specific packaging.
The expected build commands per language (documented in canonical example projects):
| Language | Command | Output |
|---|---|---|
| Rust | cargo build --target wasm32-unknown-unknown --release | target/wasm32-unknown-unknown/release/<name>.wasm |
| AssemblyScript | npx asc src/main.ts -o build/contract.wasm --target release | build/contract.wasm |
| Go (TinyGo) | tinygo build -target=wasm-unknown -o build/contract.wasm | build/contract.wasm |
| C / C++ | clang --target=wasm32 -nostdlib -Wl,--no-entry -o build/contract.wasm src/*.c | build/contract.wasm |
The path in [contract.lang.output] tells otigen build where to find the .wasm. If absent, the build fails with BuildRejected: WasmNotFound(<expected_path>).
5.1 Toolchain pinning
[contract.lang.toolchain] declares which compiler version the contract was built against. otigen build does not enforce this (it doesn't invoke the compiler) but it records the values in the bundle manifest. otigen verify uses these values to detect cross-toolchain drift.
6. pyde.abi custom-section injection
The mechanism by which otigen build integrates ABI metadata into the WASM artifact.
6.1 What gets embedded
A ContractAbi struct, Borsh-encoded.
The canonical shape is defined in HOST_FN_ABI_SPEC.md §3.7 — every byte the chain side reads at deploy time. The struct is deliberately lean: only what the chain's dispatch wrapper needs at runtime (per-function name + selector + attribute bitfield + access list, plus the schema hash + dispatch indices).
For reference, repeated here:
#![allow(unused)] fn main() { struct ContractAbi { pyde_abi_version: u32, // monotonic; matches engine's supported ABI version contract_type: ContractType, // Contract | Parachain functions: Vec<FunctionAbi>, state_schema_hash: [u8; 32], // Blake3 of canonical state-schema bytes constructor_index: Option<u32>, fallback_index: Option<u32>, receive_index: Option<u32>, } struct FunctionAbi { name: String, selector: [u8; 4], // = Blake3(name)[..4] attributes: u32, // bitfield (see HOST_FN_ABI_SPEC §3.5) access_list: Vec<String>, // declared state-slot access patterns } }
The lean shape is intentional. Two design decisions follow from it:
-
Events are not embedded in
pyde.abi. Event metadata (signature, indexed fields, topic-hash derivation) is a runtime convention: contracts callhost_emit_event(topics, data)and the chain stores topics + data verbatim. Wallets and indexers reconstruct event semantics from the event signature alone (the canonical encoding of which is documented in HOST_FN_ABI_SPEC §14.1). The bundle'sotigen.toml(shipped alongsidecontract.wasmper §9) carries the[events.X]declarations for tooling that wants the full picture. -
Function
inputs/outputsare not embedded either. The chain dispatches by selector — it does not need typed parameter or return-value metadata to invoke a function. Wallets that want to construct calldata from typed arguments read the bundle'sotigen.toml(or its richerabi.jsonmirror, per §9.3) which retains the[functions.X]inputs/outputslists.
If the implementation and this document disagree on the byte shape, HOST_FN_ABI_SPEC.md §3.7 is authoritative.
6.2 Injection mechanism
otigen build uses the wasm-encoder Rust crate (or equivalent) to inject a custom section into the .wasm:
#![allow(unused)] fn main() { use wasm_encoder::{CustomSection, Module}; let mut module = Module::new(); // ... copy all sections from the input WASM ... module.section(&CustomSection { name: "pyde.abi", data: borsh::to_vec(&contract_abi)?, }); let final_wasm: Vec<u8> = module.finish(); }
The code section is untouched — otigen does not modify a single executable byte. Only a new metadata section is appended.
6.3 Verification
On deploy, the chain's deploy validator parses the pyde.abi custom section and re-runs every check from otigen build §3.2 step 3-5 against the actual WASM bytes. This is defense in depth: a malicious author could hand-edit the pyde.abi section to bypass the build check, but the deploy validator would catch it.
See HOST_FN_ABI_SPEC §3.7 for the chain side of this contract.
7. Wallet integration
7.1 Keystore format
JSON file (default location ~/.pyde/keystore.json). One file holds multiple accounts:
{
"version": 1,
"accounts": {
"deployer": {
"address": "0xabcd...",
"pubkey": "...base64 FALCON-512 pubkey (~897 bytes)...",
"ciphertext": "...AES-256-GCM ciphertext of the FALCON secret key...",
"salt": "...random 16 bytes for Argon2id...",
"nonce": "...random 12 bytes for AES-GCM...",
"kdf": {
"name": "argon2id",
"memory_kb": 65536,
"iterations": 3,
"parallelism": 4
}
}
}
}
Decryption: key = Argon2id(password, salt, kdf_params); secret_key = AES-256-GCM-Decrypt(ciphertext, key, nonce).
7.2 Key generation
otigen wallet new runs:
- Generate a fresh FALCON-512 keypair via
pyde-crypto. - Prompt the user for a password.
- Derive
key = Argon2id(password, random_16_byte_salt, kdf_params). - Encrypt the secret key:
ciphertext = AES-256-GCM-Encrypt(secret_key, key, random_12_byte_nonce). - Compute the address:
addr = Poseidon2(falcon_public_key_bytes)(full 32 bytes, no truncation). Matches Chapter 11 §11.2 and theaddress-naming-collisionlocked-in derivation — every EOA on Pyde isPoseidon2(falcon_public_key_bytes). The input is the raw 897-byte FALCON-512 public key; the output is the full 32-byte Poseidon2 hash. - Append the entry to the keystore.
7.3 Signing pipeline
For every tx-submitting subcommand (deploy, upgrade, etc.):
- Build the canonical tx bytes per the chain's tx format (Chapter 11).
- Compute
tx_hash = Blake3(canonical_tx_bytes). - Load the keystore entry. Prompt for password (or use cached if
--cache-passwordwas passed). - Decrypt the secret key (§7.1).
signature = FALCON-512-Sign(tx_hash, secret_key).- Attach the signature + pubkey to the tx.
- Submit via JSON-RPC.
The decrypted secret key is held in memory only for the duration of the signing operation, then zeroized.
7.4 Hardware-wallet bridge
Out of scope for v1. The keystore is software-only.
Post-v1, a WalletBackend trait will allow hardware wallets (Ledger / Trezor / dedicated FALCON HSM devices) to be plugged in behind the same API. The [wallet] table will gain a backend = "hardware-ledger" | "hardware-trezor" | "software" field.
8. Deploy, upgrade, and lifecycle flow
8.1 Deploy transaction
DeployContractTx {
sender: [u8; 32],
name: String, // contract name (registered in name registry)
wasm_bytes: Vec<u8>, // .wasm with embedded pyde.abi
contract_type: ContractType,
init_calldata: Vec<u8>, // calldata for the constructor (if any)
deploy_fee: u128,
nonce: u64,
gas_limit: u64,
gas_price: u128,
sig: FalconSignature,
pubkey: FalconPubkey,
}
Chain handling on DeployContractTx:
- FALCON-verify the signature.
- Validate nonce, balance for
deploy_fee + gas_limit × gas_price. - Parse the
pyde.abicustom section fromwasm_bytesand validate (per HOST_FN_ABI_SPEC §3.7). - Register the contract name. Compute the contract address (see Chapter 11).
- Store
wasm_bytesin state at the contract's code slot. - If a constructor is declared, instantiate the WASM and invoke the constructor with
init_calldata. - Emit a
ContractDeployedevent.
8.2 Upgrade transaction — v2-deferred; v1 uses the proxy pattern
v1 does NOT ship a chain-side UpgradeContractTx tx type or an Account::Contract.owner field. The frozen TxType enum has 14 variants (crates/types/src/tx.rs); none of them are contract upgrade.
The v1 upgrade story is the proxy / delegate_call pattern, demonstrated by the upgradeable-proxy acceptance contract:
- Deploy a thin
proxycontract that holdslogic: Address+admin: Addressin its state. - Every entry on the proxy is
forward(function: String, calldata: Vec<u8>)whichdelegate_calls intologic— the delegated code runs in the proxy's frame, so state writes land in the proxy's slots. - Admin-gated
upgrade_to(new_logic)swapslogicon the proxy. State survives the swap because it lives at the proxy's address; the new logic's code is unchanged code at its own address.
Why deferred rather than shipped:
- Chain-blessed contract ownership (an
Account::Contract.ownerfield) competes with the deliberately-ownerlessaddress-naming-collisionmodel — addresses arePoseidon2(name), ownership is contract-internal. - Chain-level upgrade is less flexible than the proxy pattern: the proxy can hold multiple logic versions, time-lock swaps, gate them on governance, expose multi-sig admin, etc. — all expressible in contract code.
- Versioning, code-cf GC, owner-rotation semantics, and parachain-governance-cert-gated upgrades all hang off the chain-side variant; none of them earn their keep before v1 mainnet.
For parachains: governance-cert-gated runtime upgrades remain documented in PARACHAIN_DESIGN §6.2 as a v2 deliverable; v1 parachains are pinned to a fixed runtime.
8.3 Pause / Unpause / Kill — contract-internal in v1; no chain-side tx types
v1 does NOT ship PauseContractTx, UnpauseContractTx, or KillContractTx. Contract-level pause / kill are not protocol surface; any author can declare a paused: bool (or killed: bool) field in [state] and gate their entry points on it:
[state]
paused = { type = "bool" }
admin = { type = "address" }
#![allow(unused)] fn main() { #[pyde::entry] pub fn do_thing(...) { if storage::paused_get() { pyde::revert("contract paused"); } // ... } #[pyde::entry] pub fn pause() { if pyde::ctx::caller() != storage::admin_get() { pyde::revert("not admin"); } storage::paused_set(true); } }
Note that TxType::EmergencyPause / TxType::EmergencyResume (0x0B / 0x0C) are chain-wide — they freeze block production via the treasury multisig per Chapter 15 governance. They are NOT per-contract.
How otigen-cli handles this today
The otigen pause / unpause / kill / upgrade CLI subcommands build a Standard tx with data = borsh(LifecyclePayload::{Pause, Unpause, Kill, Upgrade}). The chain decodes contract-call data as CallPayload { function, calldata } and reverts on the unrecognised envelope.
To avoid users pointing live txs at this broken path, the CLI refuses to submit by default — see §3.5.1 (the EngineNotReady gate). The four subcommands return exit 1 with a clear error pointing at the v1 alternatives (proxy upgrades, author-declared pause/kill booleans). --i-know-engine-rejects opts past the gate for CI / engine-side handler development.
The replacement story going forward:
- Upgrade: the proxy /
delegate_callpattern above is the v1 surface. Theotigen upgradeCLI subcommand stays around for the day chain-sideTxType::Lifecycleships; until then it refuses to submit. - Pause / Unpause / Kill: author-defined entries (the
pause()/unpause()/kill()functions in your contract's[functions.*]). Call them generically viaotigen call <contract> pause --from <admin>. The dedicatedotigen pause / unpause / killsubcommands also stay around behind the engine gate for the eventual chain-side variant.
9. Artifact format
9.1 The deploy bundle
otigen build produces a directory:
./artifacts/<contract_name>.bundle/
contract.wasm # WASM binary with embedded pyde.abi custom section
otigen.toml # verbatim copy of the source config
abi.json # human-readable ABI mirror
manifest.json # build metadata
9.2 manifest.json
{
"bundle_format_version": 1,
"name": "my-token",
"contract_type": "contract",
"build_timestamp": "2026-05-23T16:42:00Z",
"otigen_version": "1.0.0",
"pyde_abi_version": 1,
"target_chain_id": 1,
"wasm_hash_blake3": "0xabcd...",
"wasm_size_bytes": 152384,
"pyde_abi_hash_blake3": "0x1234...",
"pyde_abi_size_bytes": 1840,
"language": "rust",
"language_toolchain": {
"rust_channel": "stable",
"rust_toolchain": "1.93"
}
}
Field semantics — three distinct version fields, separately governed:
| Field | What it versions | Authoritative source |
|---|---|---|
bundle_format_version | On-disk layout of <contract>.bundle/ (directory structure, file names, field shapes inside manifest.json / abi.json). | §9.3 below + otigen_abi::BUNDLE_FORMAT_VERSION constant |
pyde_abi_version | Chain-facing pyde.abi custom section embedded inside the WASM. Bumped on every breaking schema change to ContractAbi. | HOST_FN_ABI_SPEC.md §3 + otigen_abi::PYDE_ABI_VERSION_V1 constant |
otigen_version | Toolchain build that produced the bundle. SemVer; informational (used by otigen verify diagnostics, not by gating). | Cargo.toml [package].version of the otigen-cli crate |
The contract's own semantic version lives in [contract].version in the source otigen.toml; it's not in manifest.json because it's already in the verbatim-copied otigen.toml shipped alongside.
9.3 Bundle format version + forward-compat
bundle_format_version is a monotonic integer stamp on the bundle layout. Frozen at v1 mainnet under a one-way ratchet:
otigen verifyrejects unknown bundles. A bundle declaringbundle_format_version > BUNDLE_FORMAT_VERSION(the constant this otigen build was compiled against) is rejected withBundleFormatTooNew+ an "upgrade your otigen" diagnostic + exit code 2 (RESOURCE_FAILURE). Mirrors the chain'sMAX_SUPPORTED_ABI_VERSIONgate forpyde_abi_version.- Older bundles never break. Every prior
bundle_format_versionis accepted forever. Subsequent toolchain releases that change the bundle layout bump the constant and document the delta here. - Legacy bundles read cleanly. Bundles built before the
version→bundle_format_versionrename (manifest still has"version": 1) are accepted; verify falls back to reading the unnamed field with the same semantics. Both decode tobundle_format_version = 1.
The constant lives in otigen-abi, re-exported as otigen_abi::BUNDLE_FORMAT_VERSION. Tooling that wants to introspect bundles without depending on the full toolchain reads the JSON field directly.
9.4 abi.json
The same ContractAbi data structure as the embedded pyde.abi custom section, but serialized as JSON for human inspection and IDE / explorer tooling. Authoritative source is the embedded custom section; abi.json is a mirror.
9.5 Reproducibility
Two builders running otigen build from the same:
- Source code
otigen.toml- Language toolchain version
otigenversion
should produce byte-identical contract.wasm and manifest.json (modulo build_timestamp). otigen verify exists to confirm this property.
10. Diagnostics and CI mode
10.1 Verbose mode
-v shows informational logs (which file is being read, which step is running). -vv adds debug-level logs (HTTP requests, key derivation timings, etc.).
10.2 JSON output mode
--json causes every subcommand to emit one JSON object per logical event, one per line (NDJSON-style). CI / scripting consumers parse this stream; human readers see a friendlier format by default (omit --json).
{"event":"build_start","config_path":"otigen.toml"}
{"event":"config_validated","contract_name":"my-token","language":"rust"}
{"event":"wasm_loaded","path":"target/wasm32-unknown-unknown/release/my_token.wasm","size_bytes":152384}
{"event":"validation_passed","checks":["wasm_well_formed","imports_allowed","abi_consistent"]}
{"event":"abi_built","function_count":6,"event_count":2}
{"event":"abi_injected","bytes_added":1840}
{"event":"bundle_written","path":"./artifacts/my-token.bundle"}
{"event":"build_success","duration_ms":248}
Stability contract (otigen-events-v1)
This is otigen-events-v1 — the JSON event surface as of bundle_format_version = 1. The stability guarantees:
- Existing event variants never disappear.
build_start,test_pass,verify_result, etc., emit forever with their existing required fields. Older parsers keep working. - New fields may be added to existing events. Parsers MUST tolerate unknown keys (the standard "don't break on additions" JSON discipline).
- Required fields keep their types. A
duration_msthat'su64today won't become a string. Asize_bytesthat'susizetoday won't go signed. - New event variants may be added in later toolchain releases. Parsers SHOULD tolerate unknown
eventvalues (typically by logging + skipping). - Breaking changes (renamed field, type change, removed variant) bump the schema to
otigen-events-v2. The bundle'sbundle_format_versionbumps simultaneously so consumers can gate by either.
--quiet × --json interaction: --quiet wins. Both flags together emit nothing on stdout (only structured errors go to stderr via the regular error path). Useful for CI that only cares about exit codes.
Event catalog (v1, complete)
Grouped by subcommand. Each row lists the event discriminant and the fields the variant carries.
otigen init
| Event | Fields |
|---|---|
init_start | name, lang, kind |
init_success | name, lang, path, files_written |
otigen new
Currently piggy-backs on init_start (see commands/new.rs) for parity with the canonical-template path. Dedicated new_* events land in a follow-up.
otigen build / otigen build --compile
| Event | Fields |
|---|---|
build_start | config_path |
config_validated | contract_name, language |
compile_start | language, command (only with --compile) |
compile_success | language, output (only with --compile) |
compile_failed | language (only with --compile) |
wasm_loaded | path, size_bytes |
validation_passed | checks (array of check names) |
abi_built | function_count, event_count |
abi_injected | bytes_added |
bundle_written | path |
build_success | duration_ms |
otigen check
| Event | Fields |
|---|---|
check_start | config_path |
check_success | function_count, event_count, duration_ms |
check_failed | violations (count) |
otigen wallet
| Event | Fields |
|---|---|
wallet_created | name, address, keystore |
wallet_imported | name, address, keystore |
wallet_listed | keystore, accounts (array of {name, address}) |
wallet_shown | name, address, keystore |
wallet_deleted | name, keystore |
wallet_password_rotated | name |
wallet_exported | name, path, keystore |
wallet_signed | name, tx_hash, signature |
otigen deploy
| Event | Fields |
|---|---|
deploy_start | name, network, from, bundle |
deploy_dry_run | tx_hash, bytecode_hash, … |
deploy_submitted | tx_hash |
deploy_included | tx_hash, status |
deploy_failed | reason, detail |
otigen upgrade / pause / unpause / kill (lifecycle ops)
| Event | Fields |
|---|---|
lifecycle_start | op, target, network, from |
lifecycle_submitted | op, tx_hash |
lifecycle_included | op, tx_hash, status |
lifecycle_failed | op, reason, detail |
otigen inspect
| Event | Fields |
|---|---|
inspect_start | target, network |
inspect_result | target, address, account_type, balance, nonce, code_hash, code_size_bytes, state_root, plus optional ABI summary fields |
otigen test
| Event | Fields |
|---|---|
test_suite_start | file, total |
test_start | name |
test_pass | name, duration_ms, gas_used |
test_fail | name, duration_ms, gas_used, reason |
test_suite_done | passed, failed, skipped |
otigen verify
| Event | Fields |
|---|---|
verify_start | target, network, bundle |
verify_result | target, network, address, local_wasm_size, chain_wasm_size, local_wasm_hash, chain_wasm_hash, matches, optional first_diff_offset, bundle |
Authoritative source is the Event enum in crates/otigen-cli/src/events.rs; if the table above ever disagrees with the enum, the enum wins (and the spec is the bug).
10.3 Exit codes
Standardized across all subcommands:
| Code | Meaning |
|---|---|
0 | Success |
1 | Validation / logic failure (bad config, ABI inconsistency, etc.) |
2 | Resource failure (file not found, network unreachable, etc.) |
3 | Transaction failure (revert, gas exhausted, sub-call failed) |
4 | Wallet failure (bad password, missing keystore entry, etc.) |
5 | Authorization failure (signing party not authorized) |
64 | Unhandled internal error (should not occur in a correct implementation; report as a bug) |
10.4 Error message format
Errors include a structured prefix for easy parsing:
otigen [ERROR] BuildRejected: ViewMutatesState
function: transfer
reason: reachable via call graph from `do_transfer_internal`
mutating: pyde::sstore at offset 0x4a2
see: HOST_FN_ABI_SPEC.md §3.7 step 4
11. Versioning and compatibility
11.1 otigen binary version
otigen itself follows semver (MAJOR.MINOR.PATCH):
- MAJOR: breaking CLI / config-schema changes
- MINOR: new subcommands, new flags, new schema fields (backwards-compatible)
- PATCH: bug fixes
11.2 ABI compatibility
otigen emits a pyde_abi_version field in the bundle. The chain refuses to accept a deploy whose declared ABI is newer than the chain's supported ABI. See HOST_FN_ABI_SPEC §2.
Cross-version matrix:
| otigen | chain ABI | Compatible? |
|---|---|---|
| 1.0.x | 1.0 | ✅ |
| 1.1.x | 1.0 | ✅ (otigen down-targets to 1.0 if pyde_abi_version = "1.0.0" in otigen.toml) |
| 1.0.x | 1.1 | ✅ (chain supports older modules) |
| 2.0.x | 1.x | ⚠️ otigen 2.x defaults to ABI v2.0; users can --target-abi 1.x to downgrade |
11.3 Schema migration
When otigen introduces a new otigen.toml key in a minor version, existing configs continue to work (the new key is optional with a sensible default). otigen init produces the latest schema.
When otigen introduces a required new key, that's a MAJOR bump; otigen migrate exists to upgrade old configs.
11.4 Release pipeline
otigen ships as a pre-built binary on every v* tag push. The pipeline lives at .github/workflows/release.yml in the pyde-net/otigen repo and produces signed, reproducible artifacts:
Target matrix:
| OS | Architecture | Triple | Tarball name |
|---|---|---|---|
| Linux | x86_64 | x86_64-unknown-linux-gnu | otigen-{version}-x86_64-unknown-linux-gnu.tar.gz |
| Linux | aarch64 | aarch64-unknown-linux-gnu | otigen-{version}-aarch64-unknown-linux-gnu.tar.gz |
| macOS | arm64 | aarch64-apple-darwin | otigen-{version}-aarch64-apple-darwin.tar.gz |
| Windows | x86_64 | x86_64-pc-windows-msvc | otigen-{version}-x86_64-pc-windows-msvc.zip |
Per-platform job:
- Check out the tagged commit (no
dirtybuilds). - Install the pinned MSRV toolchain (currently 1.87).
cargo build --release --target <triple>→ producestarget/<triple>/release/otigen[.exe].tar -czf(orzipon Windows) → produces the tarball above.sha256sum→ producesotigen-{version}-{triple}.tar.gz.sha256alongside.- Upload to the GitHub Release for the tag. Releases are published cross-repo to the public mirror at
pyde-net/test-releasesunder a product-prefixed tag (otigen-vX.Y.Z) so the same mirror can host every Pyde toolchain release (engine-vX.Y.Z, …) anonymously without per-product asset name collisions. Authors install via the canonicalcurl -fsSL https://raw.githubusercontent.com/pyde-net/test-releases/main/otigen/install.sh | bashone-liner.
Signing:
Each tarball is signed via sigstore-keyless OIDC using the GitHub Actions runner's OIDC token as the identity. The signature artifacts (*.sig + *.pem) are uploaded alongside the tarball. Verification:
cosign verify-blob \
--certificate-identity-regexp '^https://github.com/pyde-net/otigen/.github/workflows/release.yml@.*$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--signature otigen-{version}-{triple}.tar.gz.sig \
--certificate otigen-{version}-{triple}.tar.gz.pem \
otigen-{version}-{triple}.tar.gz
This proves the binary was built by the pyde-net/otigen repo's own release.yml workflow, on a commit at the corresponding tag, without ever needing a long-lived signing key. Compromise of any single workflow run does not compromise prior releases.
Versioning:
Tag names are full semver (v0.1.0-testnet.0 → v1.0.0 for mainnet). Pre-release tags (-testnet.N, -rc.N) are explicitly marked as GitHub pre-releases. The tag commit's git describe output is recorded in the binary via build.rs (visible as otigen --version).
Reproducibility:
The pipeline pins the MSRV toolchain version and disables debug info to maximize byte-equality between independent rebuilds. The α.qual reproducibility test (still open) will verify two clean rebuilds of the same tag produce byte-identical tarballs (modulo the build timestamp embedded by cargo).
12. References
- Chapter 5 — Otigen Toolchain — narrative overview
- Chapter 17 — Developer Tools — what tools authors use day-to-day
- HOST_FN_ABI_SPEC.md — the chain-facing ABI this toolchain builds against
- OTIGEN_TEST_SPEC.md — contract behaviour test framework (Foundry-grade TOML)
- PARACHAIN_DESIGN.md — parachain-specific concerns (no-SDK rationale, governance, etc.)
- Chapter 11 — Account Model — address derivation, tx wire format
wasm-encodercrate — the WASM section-writerotigenuses
Document version: 0.1 (draft for v1 mainnet)
License: See repository root
otigen test — Contract Behaviour Test Spec
Status: v1 — shipped. The framework runs through pyde-engine-wasm-exec::WasmExecutor by default (same code path mainnet uses); the legacy in-process mock surface remains behind --no-engine for parachain contracts (parachain runtime ships in engine v2) and runner-side bisection.
This spec describes how Pyde contract authors write behaviour-level tests — assertions about state changes, return values, emitted events, and reverts — declaratively in a TOML file. The otigen test command instantiates the contract's .wasm in a wasmtime sandbox, runs the declared scenarios with mock host functions, and reports pass / fail per case.
1. Why this exists
otigen build validates that a contract is well-formed — it parses, imports the right host functions, exports the declared entries, doesn't reach state-mutating host calls from view functions. That's a structural check.
What it does NOT check: does the contract behave correctly?
- Does
transfer(amount)actually decrement the sender's balance? - Does it emit a
Transferevent with the right indexed fields? - Does it revert with
InsufficientBalancewhen the sender is overspending? - Does
expired()return true after a deadline has passed?
Authors today can write cargo test (or the equivalent in their language) for pure helpers, but those tests don't execute the contract through the chain's host-function surface. They can't simulate storage, can't observe events, can't catch reverts as the chain would catch them.
otigen test closes that gap. It's Pyde's equivalent of Foundry's forge test: a TOML-driven, language-agnostic test framework that runs WASM in wasmtime with mock implementations of every pyde::* host function declared in HOST_FN_ABI_SPEC.md.
2. When to use vs. when NOT to use
Use otigen test for:
- Behavioural assertions — "after
transfer, alice's balance is X and bob's is Y." - Event verification — "this call emitted exactly these events with these fields."
- Revert semantics — "this input path traps with
InsufficientBalance." - Multi-step scenarios — "alice transfers to bob, then bob transfers to carol; final state is ..."
- Cheatcode-driven tests — "after the deadline passes,
claim()reverts withExpired." - Cross-language regression — the same
.test.tomlruns against the contract regardless of source language (Rust / AssemblyScript / Go / C), as long as the resulting WASM matches the sameotigen.tomlshape.
Use your language's native test framework for:
- Pure-function unit tests — math helpers, parsing, formatting. Run them with
cargo test/npm test/go test/ your C test harness. Faster than spinning up wasmtime. - Property-based / fuzz testing of pure helpers. Use
proptest/quickcheck/ language-native fuzzers. v1otigen testis example-based; property testing lands in v2 (see §11). - Compiler integration — the language's own test framework is what catches "this trait isn't implemented" / "this import path doesn't resolve."
Use a full devnet for:
- End-to-end chain integration — actual consensus, actual mempool, actual cross-contract calls between independently-deployed contracts. The mock host functions in
otigen testare deliberately simple; they don't simulate parallel execution, gas exhaustion under load, or wave finalisation.
3. Hello world
For a token contract project laid out per spec §3.1:
my-token/
├── Cargo.toml (or package.json / go.mod / Makefile)
├── otigen.toml
├── src/
│ └── lib.rs
└── tests/
└── contract.test.toml ← THIS FILE
The minimum runnable test file:
# tests/contract.test.toml
[[tests]]
name = "ping_returns_42"
call = { function = "ping", args = [] }
expect.return_value = "42"
Run it:
otigen build --compile # build the .wasm
otigen test # discovers tests/*.test.toml, runs everything
Output:
Running 1 test in tests/contract.test.toml
✓ ping_returns_42 (1.2 ms)
test result: ok. 1 passed; 0 failed; 0 skipped
Exit 0 on all-pass, exit 1 on any failure.
4. Complete schema reference
Every TOML key the test framework understands, in order they appear in a typical file.
4.1 [accounts] — named addresses
Maps a human-readable name to a deterministic 32-byte address. The address is Blake3(name.as_bytes()) truncated / taken as-is to 32 bytes — same output every run.
[accounts]
alice = {} # address = Blake3("alice")
bob = {}
carol = { balance = "0x1000" } # pre-fund the account with 4096 quanta
dao = { balance = "1000000" } # decimal also OK; same effect
| Key | Type | Required | Description |
|---|---|---|---|
<name>.balance | hex or decimal string | no | Initial native-PYDE balance. Surfaced to the contract via pyde::balance_of(<addr>). Default 0. |
<name>.pubkey | 0x hex (897 bytes) | no | Pre-set FALCON-512 pubkey for the account. Default: deterministic-from-name. v1 ignored — pubkey-pinning matters for engine-level signature verification, which v1 contracts don't simulate at the auth-keys layer. Documented for v2. |
<name>.keypair | string | no | When set to "falcon512", the planner generates a fresh FALCON-512 keypair for this account at plan time and caches it for the test run. Required for any test that exercises pyde::falcon_verify. Tests reference the pubkey or produce signatures via the @pubkey:NAME / @pubkey_hash:NAME / @sig:NAME:args.IDX DSL prefixes (§5.5 below). |
[accounts]
alice = { keypair = "falcon512" } # generates a FALCON-512 keypair at plan time
bob = { keypair = "falcon512" }
Names are used throughout the file to refer to accounts: from = "alice", args = ["bob", "10"], storage.balances.alice = "100".
Reserved name: __contract__ resolves to the contract's own deployed address (Blake3(contract.name) — same as how the chain computes it at deploy time). Used for testing pyde::self() and self-references.
4.2 [cheats] — global cheatcodes
State the runner installs before EVERY test, overridable per-test in [tests.cheats]:
[cheats]
now = 1700000000 # pyde::wave_timestamp() returns this (unix seconds)
wave_id = 100 # pyde::wave_id() returns this
chain_id = 31337 # pyde::chain_id() returns this
gas_limit = 10_000_000 # gas budget the runner advances per call
Cheatcode catalog (v1):
| Cheat | Type | Host fn affected | Notes |
|---|---|---|---|
now | unix-seconds (u64) | pyde::wave_timestamp() | Default 0. Tests that depend on time should set this explicitly. |
wave_id | u64 | pyde::wave_id() | Default 1. |
chain_id | u64 | pyde::chain_id() | Default 31337 (devnet sentinel). |
gas_limit | u64 | Runner-side fuel budget | Default 10_000_000. Translated to wasmtime fuel 1:1 (runner default 1_000_000_000). Decremented per host call by the same gas constants the engine charges (see HOST_FN_ABI_SPEC §10). Tests that exhaust gas trap with out of fuel. |
Cheats reserved for later releases (parsed but currently a no-op):
cheats.expect_emit— pre-declare an expected event before a call sequence. Today the same effect is achieved viaexpect.eventson individual[[tests.calls]]entries (see §4.5 + §6.5).cheats.assume_balance— assume an account has at least N quanta. Reserved for the future fuzz / invariant testing mode; parsed-but-noop today.
Per-call overrides. now, wave_id, chain_id, gas can also be set on individual [[tests.calls]] entries — see §4.5. The per-call values use sticky semantics: once a call sets now = X, X persists into subsequent calls in the same test until another override fires. This models a real chain's monotonically-advancing clock and avoids the per-call-restore footgun.
[cheats]
now = 1000 # test baseline
[[tests.calls]]
function = "propose" # wave_timestamp() returns 1000
[[tests.calls]]
function = "vote"
now = 1500 # advance clock — wave_timestamp() returns 1500
[[tests.calls]]
function = "check_state" # wave_timestamp() still 1500 (sticky)
[[tests.calls]]
function = "execute"
now = 2500 # advance again
Foundry → otigen translation
Coming from Solidity / Foundry? vm.xxx() imperative cheats map to declarative TOML in otigen. Same coverage, no scope footguns, contract code stays identical between test and prod.
| Foundry imperative | otigen declarative |
|---|---|
vm.prank(addr) | from = "alice" on the call |
vm.startPrank / stopPrank(addr) | every call has its own from (no scope to forget) |
vm.deal(addr, n) | [tests.setup].balances.alice = "100" |
vm.warp(t) | [cheats] now = t (or now = per call) |
vm.roll(blockNum) | [cheats] wave_id = N (Pyde uses waves, not blocks) |
vm.chainId(id) | [cheats] chain_id = id (or per-call) |
vm.expectRevert("msg") | expect.revert = "msg" |
vm.expectEmit(...) | expect.events = [{ name = "Foo", ... }] |
vm.signMessage(key, msg) | @sig:NAME:args.IDX DSL (sigs are FALCON-512, generated at plan time) |
vm.mockCall(target, calldata, ret) | [[contracts]] secondary contracts (§4.7) |
vm.label(addr, "name") | [accounts].alice = {} — names are always used in traces |
vm.snapshot / vm.revertTo | not needed — each test starts from fresh state |
vm.recordLogs | not needed — events are always recorded for matching |
console.log(...) | pyde::debug_log(label_ptr, label_len, data_ptr, data_len) — test-only host fn captured by the runner. Surfaced at -vv verbosity; otigen build rejects it by default (strict-by-default) and otigen deploy always rejects it. Use otigen build --no-strict for local inspection only. |
4.3 [[tests]] — test case array
Each test case is a TOML table-array entry. Order in the file is the order they run; tests are independent (one's state doesn't leak into the next).
[[tests]]
name = "transfer_moves_balance" # required, unique within the file
[tests.cheats] # optional; per-test override of global cheats
now = 1800000000
[tests.setup] # optional; pre-test state
storage.balances.alice = "100"
storage.total_supply = "1000000"
[[tests.calls]] # one or more; order matters
function = "transfer"
from = "alice"
args = ["bob", "10"]
value = "0"
expect.return_value = "1"
expect.events = [
{ name = "Transfer", from = "alice", to = "bob", amount = "10" },
]
[tests.expect] # optional; final-state assertions
storage.balances.alice = "90"
storage.balances.bob = "10"
storage.total_supply = "1000000" # invariant: total unchanged
4.4 [tests.setup] — pre-test state
Installed into the mock environment before [[tests.calls]] runs.
| Field | Type | Description |
|---|---|---|
setup.storage.<field>.<key> | hex / decimal string | Named storage slot (see §5 name resolution). |
setup.storage."<raw_hex>" | hex string | Raw 32-byte slot hash → raw value bytes. Bypasses name resolution. Use when the contract's state isn't declared in [state]. |
setup.code.<account> | path to .wasm | Pre-deploys another contract's WASM at <account>'s address. v2 — multi-contract tests not yet implemented. |
setup.balances.<account> | hex / decimal | Override [accounts].<name>.balance. Useful for testing balance changes under a specific starting condition. |
4.5 [[tests.calls]] — call sequence
Each call executes a contract function in order, with its own caller / value / expectations.
| Field | Type | Required | Description |
|---|---|---|---|
function | string | yes | Exported function name. MUST match [functions.<name>] in the contract's otigen.toml. |
from | account name or 0x-hex | no | Caller address. Defaults to __zero__ (all-zeros). |
args | array of strings | no | Positional args. Decimal / 0x-hex literals for i32 / i64; named-account / hex / @pubkey:NAME / @pubkey_hash:NAME / @sig:NAME:args.IDX for typed args (address, uint128, bytes32, bytes, pubkey, sig) declared in [functions.<name>].inputs. See §5.5 for the DSL catalog. |
value | hex / decimal | no | Quanta attached to the call (visible via pyde::value()). Default "0". |
gas | u64 | no | Per-call gas budget override. Default uses [cheats].gas_limit. |
now | u64 (unix seconds) | no | Per-call wave_timestamp() override. Sticky: the new value persists into subsequent calls in the same test until another override fires. Models a real chain's monotonically-advancing clock. |
wave_id | u64 | no | Per-call pyde::wave_id() override. Sticky, same semantics as now. |
chain_id | u64 | no | Per-call chain_id() override. Sticky. Rare in practice (chain_id doesn't change across a chain's lifetime) — exists for symmetry + future cross-chain replay-protection testing. |
expect.return_value | hex / decimal / negative decimal | no | Asserted return value. Unsigned decimal and 0x-hex compare numerically (so "42" and "0x2a" match the same return). Negative decimal literals (e.g. "-10") parse as i64 and compare against the wasm result's sign-extended i64 view — useful for asserting error codes returned by host fns like pyde::cross_call (which surfaces ERR_CROSS_CALL_FAILED = -10 when its sub-call traps). |
expect.events | array of event matchers | no | Each entry MUST appear in this call's emitted events. See §6 for matching rules. |
expect.revert | string | no | If set, the call MUST trap with a reason that contains this substring. |
expect.no_revert | bool | no | Inverse: assert the call does NOT trap. Useful when an earlier call set up state that might cause an unexpected revert. |
expect.gas | u64 (dec or 0x-hex) | no | Foundry-style exact gas assertion. Fails if observed gas (wasmtime fuel delta) does not equal this value. Brittle to opcode-level codegen changes — prefer expect.gas_max unless you specifically need a snapshot. |
expect.gas_max | u64 (dec or 0x-hex) | no | Foundry-style upper bound assertion. Fails if observed gas > this value. Use as a regression guard: pick a ceiling once, the test breaks the moment a future change pushes you over it. |
4.6 [tests.expect] — final-state assertions
After every call in [[tests.calls]] has run, the runner checks these once:
| Field | Type | Description |
|---|---|---|
expect.storage.<field>.<key> | hex / decimal | Asserted final value at that named slot. |
expect.storage."<raw_hex>" | hex | Asserted final value at a raw slot hash. |
expect.balances.<account> | hex / decimal | Asserted final native-PYDE balance of the account. |
expect.no_other_storage_writes | bool | If true, assert that NO slots outside the declared expect.storage were modified by the test. Default false (would be too brittle in most cases). |
expect.events_total | u32 | If set, assert exactly N events were emitted across all calls. Helps catch accidental double-emits. |
4.7 [[contracts]] — secondary contracts for cross-contract tests
Cross-contract tests (pyde::cross_call / pyde::delegate_call targeting an external contract) require multiple contracts deployed at distinct addresses in the same test run. The [[contracts]] block declares secondaries; the primary contract is the one whose otigen.toml lives in cwd.
[[contracts]]
name = "counter-pair-b"
bundle = "../counter-pair-b/artifacts/counter-pair-b.bundle"
| Key | Type | Required | Description |
|---|---|---|---|
name | string | yes | Contract name. Used for the canonical address derivation (Poseidon2("pyde-contract:" ‖ name)) — must match the secondary's own [contract].name. Address surfaces under the same name in accounts / args / balances.<name> paths. |
bundle | path (string) | yes | Path to the secondary's .bundle/ directory, relative to the test file's location. The CLI reads <bundle>/contract.wasm. |
The planner adds each secondary's name to the resolvable-account set, so tests can write args = ["counter-pair-b", "100"], from = "counter-pair-b", or balances."counter-pair-b" = "100" without re-declaring under [accounts]. Names colliding with the primary or with each other are rejected at plan time.
Empty (the default) means single-contract mode — backwards-compatible with every existing test suite.
See otigen/examples/counter-pair-a/tests/contract.test.toml for the canonical multi-contract test pattern.
5. Name resolution
The test framework lets authors write storage.balances.alice instead of storage."0x9f3d…". The toolchain derives the hex behind the scenes.
5.1 Account name → 32-byte address
addr = Blake3(name.as_bytes())
Blake3 truncated to 32 bytes (default output size). Deterministic — alice always resolves to the same address across runs. __contract__ is special-cased to the contract's own deployed address.
This is NOT how the chain computes addresses in production — those come from Poseidon2(falcon_public_key). The test framework uses Blake3-of-name for ergonomic determinism; tests verify contract logic, not address-derivation cryptography. If a contract has logic that depends on a specific address shape, the author can override per-account:
accounts.alice = { addr = "0xabcdef..." }
5.2 Storage field name → slot hash
The contract's otigen.toml declared [state]:
[state]
schema = [
{ name = "owner", type = "address", disc = 0 },
{ name = "total_supply", type = "uint128", disc = 1 },
{ name = "balances", type = "mapping(address -> uint128)", disc = 2 },
{ name = "allowances", type = "mapping(address -> mapping(address -> uint128))", disc = 3 },
]
For a scalar field (owner, total_supply):
slot = Poseidon2(self_address ‖ field_name_bytes)
For a single-level mapping (balances):
slot = Poseidon2(self_address ‖ field_name_bytes ‖ key_addr)
For a nested mapping (allowances):
slot = Poseidon2(self_address ‖ field_name_bytes ‖ outer_key ‖ inner_key)
This is the same derivation the chain's typed-storage host fns (sstore_scalar / sstore_map<N>) use — the macro substrate emits the same slot from pyde::declare_storage!() field access. Author and test framework compute identical hashes.
5.3 Usage examples
# Scalar
setup.storage.total_supply = "1000000"
setup.storage.owner = "alice" # address-typed; resolves via [accounts]
# Single mapping
setup.storage.balances.alice = "100"
setup.storage.balances.bob = "0"
# Nested mapping
setup.storage.allowances.alice.bob = "50"
# Raw hex escape hatch (for state not declared in [state] schema)
setup.storage."0x9f3d12abcd..." = "0x42"
The toolchain reads [state] from otigen.toml and rejects any named field not in the schema with UnknownStateField: "balances" not in [state].
5.4 Event name → topic hash
[events.Transfer] in otigen.toml:
[events.Transfer]
signature = "Transfer(address,address,uint128)"
fields = [
{ name = "from", type = "address", indexed = true },
{ name = "to", type = "address", indexed = true },
{ name = "amount", type = "uint128" },
]
In the test spec:
expect.events = [
{ name = "Transfer", from = "alice", to = "bob", amount = "10" },
]
The test framework computes the topic hash (Blake3("Transfer(address,address,uint128)")), looks up the field positions + indexed flags, and compares against the captured emit_event calls. Indexed field values are matched as topic-tail entries; non-indexed are decoded from the Borsh-encoded data payload.
Raw-hex escape hatch (for events not in the schema):
expect.events = [
{ topic = "0x<topic_hex>", data = "0x<data_hex>" },
]
5.5 Typed-arg DSL — @pubkey:NAME, @sig:NAME:args.IDX, @pubkey_hash:NAME
Typed-arg marshalling covers the value-typed primitives plus three variable / hash-derived shapes that the runner resolves at plan time:
| Form | Used for type | Resolves to |
|---|---|---|
"@pubkey:NAME" | bytes | The 897-byte FALCON-512 public key of an account declared with keypair = "falcon512". |
"@sig:NAME:args.IDX" | bytes | A fresh FALCON-512 signature produced by NAME's secret key over the bytes of arg at position IDX in the same call. IDX must reference an earlier arg in the same args = [...] list. The target arg's value must be 0x-decodable bytes (a hex literal, a bytes32, or another bytes). |
"@pubkey_hash:NAME" | bytes32 | Poseidon2(falcon_pubkey) — the canonical on-chain "signer ID" for FALCON multisig contracts. |
Plain hex literals ("0x...") are accepted everywhere the typed-arg expects bytes — for bytes an even-length hex body of any length, for bytes32 exactly 64 hex chars.
[accounts]
alice = { keypair = "falcon512" }
bob = { keypair = "falcon512" }
# In a contract whose `execute` function has signature
# (address, uint128, bytes32, bytes, bytes, bytes, bytes, bytes, bytes):
[[tests.calls]]
function = "execute"
args = [
"recipient", # 0: address
"500", # 1: uint128
"0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", # 2: bytes32 — action hash
"@pubkey:alice", "@sig:alice:args.2", # 3, 4: alice's pubkey + sig over arg 2
"@pubkey:bob", "@sig:bob:args.2", # 5, 6: bob's pubkey + sig
"0x", "0x", # 7, 8: empty bytes (unused signer slot)
]
Each bytes declared input expands to two wasm i32 params (ptr + len); a length-zero bytes arg passes (0, 0) to mean "this slot is unused". address and uint128 continue to take a single i32 pointer.
The signatures generated by @sig:NAME:... are produced with pyde_crypto::falcon::falcon_sign, which uses the canonical domain-separation context "pyde-falcon-v1". Sigs that pass otigen test round-trip to a chain-side falcon_verify without re-signing.
6. Call execution model
6.1 Lifecycle
For each test case:
- Create a fresh
TestEnv:storage: HashMap<[u8;32], Vec<u8>>— emptycaller: [u8; 32]—__zero__unless overriddenvalue: u128—0balances: HashMap<[u8;32], u128>— populated from[accounts]+setup.balancesevents: Vec<EmittedEvent>— empty (mutable across calls in the same test)gas: u64—[cheats.gas_limit]now,wave_id,chain_id— from[cheats](with per-test override)
- Apply
setup.storage— populate the storage map. - For each entry in
[[tests.calls]]: a. Reset per-call:caller,valueper the entry; keep storage / events / balances accumulated. b. Look up the exported function in the WASM module. c. Parseargsto wasmtimeVals using the function's signature. d. Invoke. Wasmtime traps surface as either expected (expect.revertmatched) or test failure (unexpected trap). e. Check per-call expectations: return value, events emitted in this call, revert. - After all calls, check
[tests.expect](final-state assertions). - Report pass / fail.
6.2 Arg parsing
The runner reads each declared input from [functions.<name>].inputs and marshals args[i] accordingly:
- Primitive ints (
uint8/int8/ … /uint64/int64): decimal ("42") or0x-hex ("0x2a"). uint128/int128: same numeric forms, written as 16-byte LE into a runtime-allocated scratch region; the entry receives a pointer.address: named-account reference ("alice") or0x-hex address — 32 bytes written into scratch, entry receives a pointer.bytes32: 64 hex chars ("0x...") — 32 bytes written into scratch, entry receives a pointer.bytes: arbitrary even-length hex literal or one of the DSL forms (@pubkey:NAME/@sig:NAME:args.IDX) — written into scratch and the entry receives(ptr, len).
For spec-compliant void-void entries (HOST_FN_ABI §3.5.2), the runner writes a single borsh-encoded calldata blob of [functions.<name>].inputs values into scratch and exposes it via pyde::calldata_size + pyde::calldata_copy — the #[pyde::entry] macro decodes it into typed Rust arguments. For legacy extern "C" entries the runner falls back to direct wasm function parameters (ptr/len pairs for variable bytes, scalars for ints).
6.3 Host functions
Runtime selection. otigen test runs every contract through the engine's real WasmExecutor by default (since otigen#107) — the same code path mainnet executes. Per the project principle "same crypto / same VM everywhere across mainnet / testnet / devnet" the engine path is the source of truth and authors get the full pyde::* ABI at chain fidelity. The --no-engine flag opts back into the legacy in-process mock surface for parachain contracts (whose chain runtime ships in engine v2) and for runner-side bisection / debugging. See OTIGEN_BINARY_SPEC §3.10 for the runtime-selection table.
Engine-path host-fn surface. Every host fn declared in HOST_FN_ABI_SPEC §7 is implemented at chain fidelity — tx_hash, calldata_size, calldata_copy, consume_gas, cross_call_static, return, origin, tx_gas_remaining, hash_keccak256, beacon_get, and the rest of the ABI all behave as they would on-chain. The runner stubs nothing beyond the test-only debug_log (printf-style; not registered chain-side, see §7).
Legacy mock surface (--no-engine only). The legacy path runs each contract in an in-process wasmtime instance wired to test-runner mocks. The runner implements the read/write/event/balance/hash subset that the v1 substrate covers; the rest trap with UnsupportedHostFn. Useful when a contract genuinely needs the legacy path (parachain) — for everything else, the engine path is strictly more accurate.
| Host fn | Legacy-path (--no-engine) mock |
|---|---|
sload(slot_ptr, out_ptr, out_max_len) | Reads storage[slot] if present; writes up to out_max_len bytes and returns the actual length, or -1 (SLOAD_MISSING) on miss. |
sstore(slot_ptr, val_ptr, val_len) | Writes val_len bytes (≤ 16 KB) to storage[slot]. |
sdelete(slot_ptr) | Removes storage[slot]. Subsequent sload returns -1. |
caller(addr_out_ptr) | Writes env.caller (32 bytes) into wasm memory. |
self_address(addr_out_ptr) | Writes env.contract_address (32 bytes) into wasm memory. |
tx_value(value_out_ptr) | Writes env.value as 16-byte little-endian u128. |
balance(addr_ptr, out_ptr) | Reads env.balances[addr]; writes 16-byte LE u128. |
transfer(to_ptr, amount_ptr) | Decrements env.balances[caller], increments env.balances[to]; reverts on underflow. amount_ptr references a 16-byte LE u128 per HOST_FN_ABI_SPEC §7.2. |
wave_id() | Returns cheats.wave_id as i64. |
wave_timestamp() | Returns cheats.now as i64. |
chain_id() | Returns cheats.chain_id as i64. |
emit_event(topics_ptr, n_topics, data_ptr, data_len) | Appends to env.events. |
revert(msg_ptr, msg_len) | Captures the reason + traps the wasm. |
hash_poseidon2(input_ptr, input_len, out_ptr) | Real Poseidon2 via pyde-crypto. Authors using this for slot derivation in source code will produce the same slots the test framework expects. |
hash_blake3(input_ptr, input_len, out_ptr) | Real Blake3 via pyde-crypto. Same parity rationale (event topic-0, address derivation). |
falcon_verify(pk_ptr, msg_ptr, msg_len, sig_ptr, sig_len) | Real FALCON-512 verification via pyde_crypto::falcon::falcon_verify — same primitive the engine uses, so a sig that passes otigen test will pass on-chain. Returns 0 on valid, ERR_SIGNATURE_INVALID = -17 on invalid (malformed pubkey/signature bytes are also rejected as "invalid" rather than trap). |
delegate_call(target_ptr, fn_name_ptr, fn_name_len, calldata_ptr, calldata_len, gas_limit, return_data_out_ptr, return_data_out_len_ptr) | Re-enters the same wasm Instance at a named export, preserving the caller's storage context per HOST_FN_ABI_SPEC §7.8. v1 limitation: target must equal self_address — the proxy + impl must live in the same wasm. Multi-contract delegate (target = a different contract's code) requires multi-module runner support and is planned. The target export must take the canonical (calldata_ptr: i32, calldata_len: i32) -> i32 shape; the runner passes the contract's original calldata_ptr / calldata_len through unchanged (same linear memory, no copy). Return-data plumbing through return_data_out_* is zero-len in v1 — the inner can still surface state changes via the shared storage. |
cross_call(target_ptr, fn_name_ptr, fn_name_len, calldata_ptr, calldata_len, value_ptr, gas_limit, return_data_out_ptr, return_data_out_len_ptr) | Synchronous call into another contract (§7.8). Multi-contract tests declare secondaries via [[contracts]] (§4.7); each gets its own Instance + storage namespace (slots are field-keyed by self_address, so isolation is implicit). The mock: looks up target Instance, snapshots storage / balances / events, transfers value from caller to target (parent frame), switches active context (caller, contract_address, instance, scratch_base, tx_value), copies calldata from caller's memory into the callee's separate linear memory at the callee's scratch_base, invokes the named export with the canonical (calldata_ptr, calldata_len) -> i32 shape, then restores context. Sub-call trap → snapshot restore + return ERR_CROSS_CALL_FAILED = -10 (parent doesn't trap; gets the rc back and decides). Author-config errors (unknown target / missing export / wrong signature) DO trap loudly. Inside the callee, caller() returns the immediate caller-contract's address (= active address at call time, not the tx originator); tx_value() returns the cross_call's value parameter. |
parachain_storage_read(key_ptr, key_len, value_out_ptr, value_out_len_ptr) | Variable-length kv read namespaced by active parachain address per §8.1. Caller pre-writes *value_out_len_ptr with the max bytes the buffer can accept (u32 LE). Mock copies up to that limit, writes the actual length back so callers detect truncation. Returns 0 on success (including "key never written" — len 0) or ERR_OUTPUT_BUFFER_TOO_SMALL = -7 if the value exists but the buffer was too small. |
parachain_storage_write(key_ptr, key_len, value_ptr, value_len) | Variable-length kv write at (active_address, key). Overwrites any existing value. Returns 0. |
parachain_storage_delete(key_ptr, key_len) | Remove (active_address, key). No-op if absent. Returns 0 in both cases. |
parachain_id(out_ptr) | Writes the active parachain's 32-byte ID. In the v1 runner this equals caller.data().contract_address (same as self_address()); the spec §8.2 derivation uses the "pyde-parachain:" prefix when real chain code computes it — contract code is byte-identical between prefixes since it just calls the host fn. |
parachain_version() | Returns TestEnv.parachain_version as i32 (defaults to 1; future cheat enables upgrade-flow demos). |
parachain_emit_event(topics_ptr, topics_count, data_ptr, data_len) | Delegates to the core emit_event mock. The §8.3 difference — event record carries the parachain ID as contract_addr — is implicit because the active address IS the parachain's at call time. |
Other host fns (origin, tx_hash, tx_gas_remaining, calldata_*, hash_keccak256, cross_call_static, consume_gas, beacon_get, DKG, send_xparachain_message, threshold_encrypt, threshold_decrypt) | Not mocked on the legacy path. Calls trap with UnsupportedHostFn. Use the default engine path (drop --no-engine) — it implements all of these at chain fidelity. |
Slot-derivation invariant. Both the legacy raw sload / sstore host fns and the typed-storage family (sstore_scalar / sload_scalar / sstore_map1…map3) derive slots via Poseidon2(self_address ‖ field_name ‖ keys...). The macro substrate (pyde::declare_storage!() field access) emits the same hash. The engine path exercises the typed family end-to-end; the legacy mock above stubs it for --no-engine runs.
6.4 Revert semantics
A contract calls pyde::revert(msg_ptr, msg_len) to signal a revert. The mock:
- Reads the message bytes from wasm memory.
- Stores the reason in
env.revert_reason. - Returns a wasmtime trap (host-side error).
The runner catches the trap, checks env.revert_reason, and matches against expect.revert via substring containment. Foundry-style:
expect.revert = "InsufficientBalance"
# Matches reverts where reason is exactly "InsufficientBalance", or
# "Error: InsufficientBalance(alice has 5, needs 10)", etc.
Wasmtime traps from causes OTHER than revert (out-of-bounds memory, integer overflow, unreachable opcode) surface as expect.revert matches if the contract explicitly mapped its revert reason; otherwise they're an unexpected-trap test failure.
6.5 Event matching
For each entry in expect.events:
- Compute the expected topic-0 hash:
- If
nameis given AND[events.<name>]is declared in the contract'sotigen.toml, computeBlake3(signature). - If
topicis given, use the literal hex (raw-hex escape hatch). - If
nameis given BUT no[events.<name>]is declared, fall through to the shape-only check — match passes if any event was emitted. See "Shape-only fallback" below.
- If
- Compute expected indexed-field topics (for each
indexed = truefield):- If the value is an account name, resolve via
[accounts]. - If decimal / hex, encode as 32-byte left-padded big-endian (matching how
Hash(value)is computed for indexed fields per Ch 4).
- If the value is an account name, resolve via
- Compute the expected data payload (for non-indexed fields):
- Borsh-encode the listed values in field-declaration order. Width is type-determined: u8/i8/bool = 1, u16/i16 = 2, u32/i32 = 4, u64/i64 = 8, u128 = 16, address = 32. Authors who skip a non-indexed field in the matcher have that field's width skipped in the data cursor (wildcard match).
- Scan
env.eventsfor at least one entry whose(topic_0, topics_indexed, data)exactly matches.
Ordering is NOT enforced — events may be emitted by helper functions in arbitrary order. The assertion is existence, not sequence. (If ordering matters, the test can assert per-call events under expect.events inside the specific [[tests.calls]] block.)
Supported field types (v1)
| Type | Topic encoding (indexed) | Data encoding (non-indexed) |
|---|---|---|
address | 32 bytes (account name → Blake3, or raw hex) | 32 bytes (same) |
uint8 / int8 / bool | 32-byte BE-padded | 1 byte LE |
uint16 / int16 | 32-byte BE-padded | 2 bytes LE |
uint32 / int32 | 32-byte BE-padded | 4 bytes LE |
uint64 / int64 | 32-byte BE-padded | 8 bytes LE |
uint128 | 32-byte BE-padded | 16 bytes LE |
Other types (bytes, dynamic arrays, custom structs) fall through to shape-only matching in v1; full type-aware matching lands in v2 alongside the rest of the Borsh decoder.
Shape-only fallback
If a matcher uses name = "X" but the contract's otigen.toml doesn't declare [events.X], the runner can't compute the expected topic-0 or know the field layout — so it falls back to "any event was emitted" as a conservative existence check. This is useful for contracts that emit events declared only in source (not surfaced in otigen.toml), but it's strictly weaker than the schema-driven match. Authors who want precise matching declare the event in otigen.toml or use the raw-hex form.
6.6 Gas tracking (Foundry-style)
The runner enables wasmtime's consume_fuel(true) and seeds every call with a fuel cap (from cheats.gas_limit if set; otherwise a runner-internal default of 1,000,000,000 fuel units). Per-call gas usage is computed as fuel_cap - remaining_fuel after the call returns.
What the runner records per test (the TestReport returned alongside TestOutcome):
| Field | Source | Used by |
|---|---|---|
gas_used | Sum of per-call fuel deltas | otigen test -v (and above); NDJSON test_pass/test_fail events |
events | TestEnv.events at test end | otigen test -vv |
call_traces | One per [[tests.calls]] (function, args, return, revert, gas) | Reserved — surfaced only on NDJSON today |
storage_diffs | Slot-by-slot before/after | Reserved — surfaced only on NDJSON today |
The runner's fuel units correlate to but are not bit-identical with on-chain Pyde gas. Foundry has the same caveat — its gas reports are estimates, not chain billing. For ground-truth gas, deploy to a devnet and pull the receipt.
Per-call gas assertions (expect.gas / expect.gas_max, see §4.5) are checked after each call's per-call expect.return_value / expect.events block. A gas assertion failure produces a test fail with the reason call[N]: expect.gas[_max] = X; observed Y.
7. CLI surface
7.1 Discovery
otigen test looks for test files in this order:
tests/*.test.toml— the canonical location.tests/*.toml— for projects with a single test file../contract.test.toml— single-file projects.
Each file's [[tests]] array contributes to the total test count.
7.2 Flags
otigen test [-v|-vv] [--filter <pattern>] [--bundle <path>] [--dry-run] [--watch] [--no-engine] [--no-compile]
| Flag | Default | Description |
|---|---|---|
--filter <pattern> | none | Run only tests whose name contains the pattern (substring match). Multiple --filter flags are OR'd. |
--bundle <path> | ./artifacts/<name>.bundle/ | Path to the deploy bundle whose contract.wasm should be executed. Defaults to what otigen build produces. |
--dry-run | off | Parse + plan the test scenarios; print the plan without invoking wasmtime. Useful for catching schema errors fast. |
--watch | off | Re-run the suite on source / TOML change. Foundry-parity. |
--no-engine | off | Run through the legacy in-process mock surface instead of the chain's WasmExecutor. Reserved for parachain contracts (engine v2) and runner-side bisection. |
--no-compile | off | Skip the per-language compile step; reuse the existing .wasm on disk. |
--json (global) | off | Emit NDJSON events per test, one per line. CI consumes. |
-v / -vv | off | Standard clap verbosity counting. -v enables gas-per-test on the human formatter; -vv adds events + captured pyde::debug_log entries. Per-call trace + storage-diff verbosity tiers are reserved (parsed but no-op today). |
7.3 Output
Human format (default):
Running 3 tests in tests/contract.test.toml
✓ ping_returns_42 (0.8 ms)
✓ transfer_moves_balance (1.2 ms)
✗ transfer_reverts_on_overspend
expected revert containing "InsufficientBalance"
got: return value 0 (no trap)
test result: FAILED. 2 passed; 1 failed; 0 skipped
--json NDJSON format:
{"event":"test_suite_start","file":"tests/contract.test.toml","total":3}
{"event":"test_start","name":"ping_returns_42"}
{"event":"test_pass","name":"ping_returns_42","duration_ms":0.8}
{"event":"test_start","name":"transfer_moves_balance"}
{"event":"test_pass","name":"transfer_moves_balance","duration_ms":1.2}
{"event":"test_start","name":"transfer_reverts_on_overspend"}
{"event":"test_fail","name":"transfer_reverts_on_overspend","reason":"expected revert containing \"InsufficientBalance\", got: return value 0 (no trap)"}
{"event":"test_suite_done","passed":2,"failed":1,"skipped":0}
7.4 Exit codes
| Code | Meaning |
|---|---|
0 | Every test passed. |
1 | At least one test failed. Per-test reasons on stderr / in NDJSON. |
2 | Resource failure (test file unreadable, .wasm not found at the declared [contract.lang.output], wasmtime engine setup failed). |
4 | Schema error in the test spec itself (malformed TOML, references an undeclared [state] field, etc.). |
8. Worked example: ERC-20-style transfer
The full file an author would write for a token contract:
# tests/contract.test.toml
[accounts]
alice = { balance = "0x100" }
bob = {}
carol = {}
[cheats]
now = 1700000000
chain_id = 31337
# ─── Test 1: happy path ───────────────────────────────────────
[[tests]]
name = "transfer_moves_balance_and_emits_event"
[tests.setup]
storage.balances.alice = "100"
storage.balances.bob = "0"
storage.total_supply = "1000000"
[[tests.calls]]
function = "transfer"
from = "alice"
args = ["bob", "10"]
expect.return_value = "1"
expect.events = [
{ name = "Transfer", from = "alice", to = "bob", amount = "10" },
]
[tests.expect]
storage.balances.alice = "90"
storage.balances.bob = "10"
storage.total_supply = "1000000" # invariant: total unchanged
# ─── Test 2: revert on overspend ──────────────────────────────
[[tests]]
name = "transfer_reverts_on_overspend"
[tests.setup]
storage.balances.alice = "5"
[[tests.calls]]
function = "transfer"
from = "alice"
args = ["bob", "10"]
expect.revert = "InsufficientBalance"
# ─── Test 3: multi-call chain ─────────────────────────────────
[[tests]]
name = "alice_to_bob_to_carol_round_trip"
[tests.setup]
storage.balances.alice = "100"
[[tests.calls]]
function = "transfer"
from = "alice"
args = ["bob", "30"]
[[tests.calls]]
function = "transfer"
from = "bob"
args = ["carol", "10"]
[tests.expect]
storage.balances.alice = "70"
storage.balances.bob = "20"
storage.balances.carol = "10"
# ─── Test 4: time-dependent revert ────────────────────────────
[[tests]]
name = "claim_reverts_after_deadline"
[tests.cheats]
now = 2000000000 # well past the contract's hard-coded deadline
[[tests.calls]]
function = "claim"
from = "alice"
args = []
expect.revert = "Expired"
Run:
$ otigen test
Running 4 tests in tests/contract.test.toml
✓ transfer_moves_balance_and_emits_event (1.2 ms)
✓ transfer_reverts_on_overspend (0.9 ms)
✓ alice_to_bob_to_carol_round_trip (2.1 ms)
✓ claim_reverts_after_deadline (1.0 ms)
test result: ok. 4 passed; 0 failed; 0 skipped
9. Limitations (explicit)
What otigen test deliberately does NOT do today:
| Limitation | Reason | Workaround |
|---|---|---|
| No parallel-execution simulation. Tests run sequentially. | The chain runs txs in parallel under access-list scheduling; the test framework doesn't. Tests are deterministic single-thread. | Real concurrency bugs caught at the chain integration layer (otigen devnet). |
| No fuzzing / property testing. Tests are example-based only. | Adding fuzzing needs a shrinker + generator + proptest-style integration. | Reserved syntax ([[tests.property]] with forall.<arg> constraints) parsed-but-noop; the real fuzz infrastructure lands as a future polish item. |
| No multi-tx context. Each test starts from fresh state; no "deploy contract in tx1, then call from a different sender in tx2" within a single test. | Tx-level isolation keeps the in-process model simple. | Explicit tx boundaries ([[tests.tx]] blocks) are a planned future expansion. For today's needs, drive multi-tx flows through otigen devnet + otigen call / otigen console against a real node. |
No simulating chain-side validators. expect.revert matches the contract's own revert; it doesn't simulate "this tx would be rejected at mempool / by the access-list check / by the nonce window". | Mempool + admit-tx validation runs on a real node; out of scope for behaviour tests. | Devnet integration tests via otigen devnet [--fork <FILE_OR_URL>] for real-chain-state context. |
Test files can't share helpers. Every .test.toml is standalone. | TOML is data, not code. | Authors who need shared setup copy the [accounts] + [cheats] blocks between files. A future [include] is reserved. |
| No mock for DKG / threshold-encryption host fns on the legacy path. | Real DKG state is committee-derived; the legacy runner has no committee. | Use the default engine path — but note threshold-decrypt / DKG host fns are themselves still planned for engine v2, so contracts exercising them will see them surface there. |
What otigen test is NOT trying to be:
- An audit replacement. It catches what authors think to test for. It doesn't prove the absence of bugs.
- A devnet substitute. Final correctness signal is a real chain integration test. Pair with
otigen devnet+otigen deploy --network devnetfor end-to-end verification. - A proof system. No formal verification, no symbolic execution. Concrete example execution only.
Shipped surface today (no longer limitations): cross-contract calls (cross_call + delegate_call + [[contracts]]), in-contract FALCON verification, gas accounting + expect.gas / expect.gas_max, typed-arg marshalling (address / uint128 / int128 / bytes32 / bytes), the FALCON DSL (@pubkey: / @pubkey_hash: / @sig:), schema-aware encoding, per-call cheats with sticky semantics, pyde::debug_log, four-level verbosity ladder, parachain §8 host fn surface on the --no-engine path, the engine path as default (running the real pyde-engine-wasm-exec::WasmExecutor — same code path mainnet uses), --watch mode for Foundry parity, struct(<Name>) typed storage values via pyde::declare_storage!().
10. Common patterns
10.1 Sentinel addresses
When a test needs an address that's neither the contract nor a named account:
[accounts]
attacker = {} # Blake3("attacker")
random = { addr = "0xdeadbeef..." } # explicit override
Both work in args / from / etc.
10.2 Initial-state seeding from a fixture
For contracts with complex initial state (a populated airdrop merkle tree, a long allowlist, etc.):
[tests.setup]
storage."0x<root_slot>" = "0x<merkle_root>"
storage."0x<allowlist_slot_alice>" = "0x01"
storage."0x<allowlist_slot_bob>" = "0x01"
# … 200 entries — typically generated by a helper script the author commits alongside the .test.toml
The author writes a small generator (Python / Bash / their language of choice) that emits the storage block. v2 may ship otigen test --seed <generator.json> but the explicit form keeps v1 self-contained.
10.3 Asserting an invariant across many tests
If total_supply should NEVER change after deploy, every test asserts it:
[[tests]]
# ... call code that should NOT change total_supply ...
[tests.expect]
storage.total_supply = "1000000" # invariant
v2 may add [invariants] declared once at the file level, auto-asserted after every test.
11. What ships today
otigen test is the production behaviour-test framework. The surface covered by the spec above is implemented end-to-end against the real engine path by default:
- TOML schema (
[accounts],[cheats],[[tests]],[tests.setup],[[tests.calls]],[tests.expect],[[contracts]]). - Name resolution (account → 32-byte Blake3 address, state field → slot hash, event name → topic-0 hash).
- Engine-path execution through
pyde-engine-wasm-exec::WasmExecutor— same code path mainnet uses; everypyde::*host fn implemented at chain fidelity. Legacy in-process mock surface still available behind--no-enginefor parachain contracts and runner-side bisection / debugging. - Multi-call sequences with per-call overlay (revert discards; success commits — matches mainnet semantics).
- Final-state assertions across storage slots + balances + event totals.
- Typed-arg marshalling for
address/uint128/int128/bytes32/bytes/ primitive ints, with named-account resolution. - Named-event matchers walking
[events.*]schemas (indexed-topic / non-indexed-data field encoding). - FALCON DSL —
@pubkey:NAME/@pubkey_hash:NAME/@sig:NAME:args.IDXwith real FALCON-512 keypair generation at plan time. - Schema-aware storage encoding via the
[state]schema vocabulary (includingstruct(<Name>)viapyde::declare_storage!()). - Per-call cheats with sticky semantics —
now,wave_id,chain_id,gason[[tests.calls]]entries. pyde::debug_logtest-only host fn captured into the test report; chain-side hard-rejected atotigen build+otigen deploy.- Foundry-style verbosity ladder (
-v/-vv/-vvv/-vvvv). --filtersubstring filter;--bundleoverride;--jsonNDJSON event stream;--watchcontinuous re-run;--no-enginelegacy-mock opt-out.
Reserved for future expansion (parsed-but-noop or noted in §9): fuzz / invariant modes ([[tests.property]]), explicit multi-tx context ([[tests.tx]]), shared-helper includes, full DKG / threshold-crypto host-fn surface.
12. Cross-references
OTIGEN_BINARY_SPEC.md— canonical CLI spec;otigen testlands as §3.10.HOST_FN_ABI_SPEC.md— every host fn the mocks must match.- Chapter 4: State Model — PIP-2 slot derivation that name resolution mirrors.
- Chapter 5: Otigen Toolchain — narrative overview;
otigen testsection lands at §5.13. - Chapter 17 §17.6 — developer tools roundup; references back here.
IMPLEMENTATION_PLAN.md— Stream α tracking,OTIGEN_TESTtrack.
If the implementation and this spec disagree, the spec is right and the code is a bug.
Pyde Implementation Plan
Version 0.1 — written 2026-05-23 after design phase completion.
This document is the coordination artifact for implementing Pyde. The design phase is done (the rest of the book + the locked specs in this companion/ directory define the protocol). This document defines:
- Who builds what — three parallel work streams, with strict crate ownership
- In what order — five sequential phases (MC-0 through MC-5)
- Against what specs — every stream points at its canonical authoritative doc
- How to avoid clashes — interface contracts frozen at Phase 0; branching protocol; coordination rules
If this document and any other artifact disagree on implementation logistics (who owns what, branching rules), this document wins. If this document and a design spec (HOST_FN_ABI_SPEC, etc.) disagree on protocol semantics, the design spec wins.
For the design philosophy ("v1 ships interfaces, v2 ships implementations") see the org memory references.
1. The three-session model
Pyde's v1 implementation is structured as three parallel work streams, each owning its own clear scope. The streams are designed to be independently parallelizable — the only synchronization point is at integration time (MC-2).
| Stream | Codename | Repository | Primary spec | What it builds |
|---|---|---|---|---|
| α | Toolchain | pyde-net/otigen (new) | OTIGEN_BINARY_SPEC.md | The otigen developer-tool binary: build, deploy, wallet, console |
| β | Execution | pyde-net/engine (new), branch execution-side | HOST_FN_ABI_SPEC.md, Chapter 4, PIPs 2/3/4 | The WASM execution layer: state, account, tx, mempool, wasm-exec |
| γ | Consensus | pyde-net/engine, branch consensus-side | Chapter 6, SLASHING.md, VALIDATOR_LIFECYCLE.md, STATE_SYNC.md, CHAIN_HALT.md, NETWORK_PROTOCOL.md | Consensus + networking + node binary |
Each stream is meant to be assignable to a single Claude session or a single human contributor and run independently for weeks at a time without coordination beyond the locked interface contracts (§4).
2. Five-phase execution timeline
MC-0 — INTERFACE FOUNDATION [SEQ — me]
│
▼
MC-1 — PROTOCOL CORE [PAR — three sessions]
│ ├─ Stream α (toolchain)
│ ├─ Stream β (execution)
│ └─ Stream γ (consensus)
│
▼
MC-2 — INTEGRATION [SEQ]
│ Merge β + γ branches; bring up local devnet
│
▼
MC-3 — STATE SYNC + PARACHAIN ACTIVATION [SEQ]
│
▼
MC-4 — PERFORMANCE + FAILURE HANDLING [PAR within]
│
▼
MC-5 — VALIDATION + MAINNET LAUNCH [SEQ]
Phase summaries in §3 below. Detailed per-phase checklists are tracked separately.
3. Phase plan
3.1 MC-0 — Interface Foundation (sequential, ~1 day)
The prerequisite to safe parallelism. Without MC-0 complete, the three streams clash on shared types and interface drift.
Deliverables:
- Fresh
pyde-net/enginerepo created on GitHub + cloned locally. - Cargo workspace skeleton with stubs for every crate listed in §5.
typescrate fully written. Every type used across crate boundaries lives here, frozen at end of MC-0. Includes:Address,SlotHash,Value,Balance,Nonce,Tx,TxHash,Receipt,StateRoot(Blake3 + Poseidon2),EventRecord,WaveId,Round,VertexHash,Vertex,WaveCommitRecord,HardFinalityCert,FalconPubkey,FalconSignature, error codes per HOST_FN_ABI_SPEC §4.interfacescrate fully written. The cross-crate traits that decouple β and γ:trait StateView— read-only state access (used by mempool validation, view-call execution)trait StateMutator— apply a wave's worth of writes atomicallytrait Executor— invoke a tx (called by consensus when committing a wave)trait MempoolView— what consensus reads from the mempooltrait NetworkView— gossipsub send/recv abstractiontrait ConsensusEngine— the consensus loop the node binary drives- Each trait ships with a mock implementation so β and γ can write tests in isolation.
- CI baseline —
.github/workflows/ci.ymlrunscargo build,cargo test,cargo clippy --workspace -- -D warnings,cargo fmt --all -- --checkon every PR. - Branching protocol documented (§6).
- Initial commit tagged
phase-0-foundation.
Owner: main session (current context). The user does not need to spin up parallel sessions until MC-0 ships.
Bar to advance to MC-1: phase-0-foundation tag landed on main; CI green; types and interfaces crates pass their own unit tests; all crate stubs compile.
3.2 MC-1 — Protocol Core (parallel, three streams)
The three streams (α, β, γ) work concurrently against the locked Phase 0 foundation.
Stream α — Toolchain (pyde-net/otigen repo)
Implements OTIGEN_BINARY_SPEC.md end-to-end. Independent of engine internals — only depends on the locked Host Function ABI spec to validate WASM modules. Specific deliverables in §3.2 of the spec; first milestone is otigen build working against the canonical Rust hello-world contract.
Crates (in pyde-net/otigen workspace):
otigen-cli— the binaryotigen-toml— config parser + schema validationotigen-abi—pyde.abicustom-section construction + injection (viawasm-encoder)otigen-rpc— JSON-RPC clientotigen-wallet— keystore (Argon2id + AES-256-GCM) + FALCON-512 signingotigen-test— wasmtime-driven contract behaviour test runner (seeOTIGEN_TEST_SPEC.md)- (later)
otigen-console— REPL
External dependencies:
pyde-crypto(sibling polyrepo) — FALCON, Argon2id, AES-GCM, Borshwasmparser,wasm-encoder(Bytecode Alliance) — WASM inspection + custom-section writingclap— CLI frameworkserde,toml— config parsingreqwest,tokio-tungstenite— HTTP + WebSocket
Stream β — Engine Execution (pyde-net/engine, branch execution-side)
Crates owned:
account— 32-byte addresses,AuthKeysenum (withProgrammablev2 reservation), 16-slot nonce window, name-registry interfacestate— JMT dual-hash,state_cf+jmt_cf+events_cf+events_by_topic_cf+events_by_contract_cf, PIP-2 clustered keys, PIP-3 prefetch, PIP-4 write-back cache, snapshot generationtx— transaction types (Transfer, ContractCall, ContractDeploy, ValidatorRegister, Multisig, etc.), canonical hashing, gas accounting, deploy/upgrade/lifecycle handlerswasm-exec— wasmtime engine config (deterministic feature subset),WasmExecutor, every host function from HOST_FN_ABI_SPEC §7-§8, module cache, fuel-to-gas mapping, per-tx overlaymempool— FALCON-verify pipeline, validation rules, gossip admission, gas-bond logic
Spec map:
HOST_FN_ABI_SPEC— every host function this stream implements- Chapter 4 — state model + dual-hash JMT
- PIPs 2, 3, 4 — state optimizations
- Chapter 11 — account model, tx wire format
- Chapter 10 — gas + fee model
- Chapter 3 — execution layer architecture, per-tx overlay
Stream γ — Engine Consensus + Networking (pyde-net/engine, branch consensus-side)
Crates owned:
consensus— Mysticeti DAG, vertex/round/anchor/wave logic, BFS subdag walk, slashing evidence collection, equivocation detection, missing-vertex fetchnet— libp2p + QUIC + Gossipsub, peer discovery (layered, no DHT), sentry-node pattern, vertex-fetch protocoldkg— Pedersen DKG protocol (or import frompyde-cryptoif it lands there first)slashing— validator state machine, the 10-offense catalog, slashing escrow, jail mechanics, reward distributionnode— the binary, JSON-RPC server, validator role,consensus_storewithset_sync(true), persistence
Spec map:
- Chapter 6 — Mysticeti DAG consensus
SLASHING.md— full 10-offense catalogVALIDATOR_LIFECYCLE.md— registration, bonding, unbonding, jailSTATE_SYNC.md— snapshot mechanics, chain-of-trustCHAIN_HALT.md— halt detection, recovery pathsNETWORK_PROTOCOL.md— libp2p config, topics, peer scoring- Chapter 12 — networking
- Chapter 16 — security (cross-references throughout)
MC-1 BAR: Each of α / β / γ runs cargo build && cargo test clean on their branch. The β + γ branches build and link against the frozen types + interfaces crates. Mock-based integration tests pass within each stream.
3.3 MC-2 — Integration (sequential)
Merge β and γ branches to main. Bring up a local devnet (4-7 validators on a single machine) producing sub-second commits with end-to-end tx flow:
- Author writes a contract (with α's otigen), builds locally, deploys via
otigen deploy. - Tx submitted to RPC, validated by mempool (β), batched, gossipped (γ), included in vertex (γ).
- Anchor commits, subdag walks (γ), wasmtime executes (β).
- State updates (β), state_root signed (γ),
HardFinalityCertformed (γ). - Receipt queryable via RPC; event subscription pushes notifications.
Coordinated by the main session. Both β and γ contributors review the merge PRs. Owner of the integration milestone: γ (since node crate lives there).
MC-2 BAR: Local devnet running end-to-end. All MC-1 deliverables integrated. Performance is correct (functional), not yet measured (that's MC-4).
3.4 MC-3 — State Sync + Parachain Activation (sequential)
Add the two protocol-level extensions that depend on MC-2 being functional:
- State sync — snapshot generation, weak-subjectivity checkpoints, fresh-validator flow. Spec:
STATE_SYNC.md. - Parachain framework activation — parachain registry, deployment + lifecycle, cross-parachain messaging, governance flow. Spec:
PARACHAIN_DESIGN.md.
Owner: shared between β and γ as the changes touch both sides. Coordinated by the main session.
3.5 MC-4 — Performance + Failure Handling (parallel within)
- Performance harness build-out — multi-region workload generation, soak testing, and the publishing discipline (publish only what the harness measures under sustained, production-realistic conditions — never lab extrapolations or microbenchmark peaks). Spec:
PERFORMANCE_HARNESS.md. - Chaos / failure injection — failure-scenarios catalog walkthroughs (
FAILURE_SCENARIOS.md). - Chain halt recovery drills —
CHAIN_HALT.mdplaybooks executed in test environments.
3.6 MC-5 — Validation + Mainnet Launch (sequential)
- Five external audits (consensus, execution layer, cryptography, networking, otigen toolchain).
- Incentivized testnet (multi-month soak test with reference dApps + bug bounty at mainnet tier).
- 128-validator genesis ceremony.
- Mainnet launch.
Spec map: Chapter 19 (Launch Strategy).
Mainnet ships when the validation work passes — not before, not on a calendar.
4. Crate ownership map
The load-bearing table of this document. Every crate has exactly one owning stream. No co-ownership.
pyde-net/engine (one repo, β and γ collaborate via branches)
| Crate | Owner | Branch | Depends on |
|---|---|---|---|
types | MC-0 (frozen) | main | (none — leaf crate) |
interfaces | MC-0 (frozen) | main | types |
account | β | execution-side | types, pyde-crypto |
state | β | execution-side | types, interfaces |
tx | β | execution-side | types, account, state, pyde-crypto |
wasm-exec | β | execution-side | types, interfaces, state, account, tx |
mempool | β | execution-side | types, interfaces, account, tx |
consensus | γ | consensus-side | types, interfaces, pyde-crypto |
net | γ | consensus-side | types, interfaces |
dkg | γ | consensus-side | types, pyde-crypto |
slashing | γ | consensus-side | types, interfaces |
node | γ | consensus-side | (all of the above) |
pyde-net/otigen (separate repo, α owns entirely)
| Crate | Owner | Depends on |
|---|---|---|
otigen-cli | α | all otigen-* crates below |
otigen-toml | α | serde, toml |
otigen-abi | α | wasmparser, wasm-encoder, borsh |
otigen-rpc | α | reqwest, tokio-tungstenite |
otigen-wallet | α | pyde-crypto |
otigen-test | α | wasmtime, otigen-toml, otigen-abi, pyde-crypto |
pyde-net/pyde-crypto (existing polyrepo)
Already in place. Both engine streams + α import from it. Out of scope for new implementation work in MC-1 — only additions (DKG, PSS) added as needed.
Top-level files in pyde-net/engine
| File | Owner | Notes |
|---|---|---|
Cargo.toml (workspace) | MC-0 initially; stream adds its own dep entries | Avoid editing other streams' sections |
README.md | γ | Stream γ owns the binary so it owns documentation |
.github/workflows/ci.yml | MC-0 initially; both streams may extend their respective test stages | |
LICENSE, SECURITY.md, .gitignore | MC-0 initially | Edits via coordinated PR |
5. Interface contracts (high-level)
The traits in engine/crates/interfaces/src/lib.rs. Frozen at end of MC-0. Changes after that require a coordinated PR from both β and γ + main session approval.
#![allow(unused)] fn main() { // engine/crates/interfaces/src/lib.rs (sketch — full impl in MC-0) use pyde_engine_types::{ Address, SlotHash, Value, Balance, Tx, TxHash, Receipt, StateRoot, EventRecord, WaveId, WaveCommitRecord, Vertex, VertexHash, HardFinalityCert, }; /// Read-only state access. Implemented by `state::StateStore`. /// Used by `mempool` for validation, `wasm-exec` for sload, RPC for queries. pub trait StateView { fn get_slot(&self, slot: &SlotHash) -> Option<Value>; fn get_balance(&self, addr: &Address) -> Balance; fn get_nonce(&self, addr: &Address) -> u64; fn get_code_hash(&self, addr: &Address) -> Option<[u8; 32]>; fn state_root(&self) -> StateRoot; } /// Wave-level state mutation. Implemented by `state::StateStore`. /// Used by `consensus` to apply a committed wave's writes. pub trait StateMutator: StateView { fn begin_wave(&mut self, wave_id: WaveId); fn execute_tx(&mut self, tx: &Tx) -> Receipt; fn finalize_wave(&mut self) -> WaveCommitRecord; } /// Tx invocation. Implemented by `wasm-exec::WasmExecutor`. /// Used by `consensus` to execute committed txs. pub trait Executor { fn execute(&mut self, tx: &Tx, state: &mut dyn StateMutator) -> Receipt; } /// Mempool query. Implemented by `mempool::Mempool`. /// Used by `consensus` to pull txs into batches. pub trait MempoolView { fn drain_for_batch(&mut self, max_bytes: usize) -> Vec<Tx>; fn insert(&mut self, tx: Tx) -> Result<TxHash, MempoolError>; fn contains(&self, hash: &TxHash) -> bool; } /// Network gossip. Implemented by `net::Network`. /// Used by `consensus` for vertex / batch / share dissemination. #[async_trait] pub trait NetworkView: Send + Sync { async fn publish_vertex(&self, vertex: Vertex); async fn publish_batch(&self, batch: Batch); fn subscribe_vertices(&self) -> Receiver<Vertex>; fn subscribe_batches(&self) -> Receiver<Batch>; fn fetch_vertex(&self, hash: VertexHash) -> Future<Option<Vertex>>; } /// The consensus loop. Implemented by `consensus::ConsensusEngine`. /// Driven by `node` binary. #[async_trait] pub trait ConsensusEngine: Send { async fn run( &mut self, state: &mut dyn StateMutator, executor: &mut dyn Executor, mempool: &mut dyn MempoolView, network: &dyn NetworkView, ); } }
Each trait ships with a mock implementation in interfaces/src/mock.rs so each stream can write isolated tests:
#![allow(unused)] fn main() { // interfaces/src/mock.rs pub struct MockStateView { /* HashMap-backed */ } pub struct MockMempool { /* VecDeque-backed */ } pub struct MockNetwork { /* channel-backed */ } // ... etc. }
6. Branching + coordination protocol
6.1 Branching
main ← integration branch; both streams merge here
├── execution-side ← stream β's long-lived branch
└── consensus-side ← stream γ's long-lived branch
- Each stream merges to
mainweekly minimum (more often is fine). - Each merge is a PR with CI green; one reviewer (the other stream's session or zarah).
- After every weekly merge, each stream rebases its branch onto the latest
main.
6.2 Tagged checkpoints
phase-0-foundation— end of MC-0phase-1-α-milestone-N— α stream milestonesphase-1-β-milestone-N— β stream milestonesphase-1-γ-milestone-N— γ stream milestonesphase-2-integration-bar— local devnet running end-to-endphase-3-state-sync-live,phase-3-parachain-activationphase-4-perf-harness-baseline,phase-4-chaos-drills-passedphase-5-audit-N-passed,phase-5-mainnet-launch
6.3 Coordination rules
- No edits to
typesorinterfacescrates after MC-0 without a coordinated PR signed off by both other streams. - Crate ownership is exclusive. β does not touch γ's crates; γ does not touch β's. If a need arises, raise it as an issue first, agree on which side owns the change, then PR.
- Shared dependencies update via coordinated PR. Bumping wasmtime, libp2p, etc. is a top-level PR reviewed by both streams.
- Conflicts on
mainthat bisect crate ownership get reverted; original committer rebases.
6.4 Communication
- GitHub issues on
pyde-net/engineandpyde-net/otigenfor design questions, blocking dependencies, interface clarifications. - Spec ambiguity? Update the relevant spec in
pyde-net/pyde-bookvia PR. Both streams reference the updated spec. - Cross-stream blocker? Tag both streams' owning agents in an issue.
7. Session handoff prompts (paste-ready)
The three prompts below are designed to be self-contained — each prompt initializes a new Claude session with full context to start work on its assigned stream.
7.1 Stream α — Toolchain session prompt
# Pyde Session α — Otigen Toolchain Implementation
You're joining the Pyde Layer 1 blockchain project. Three parallel
implementation streams are running concurrently; you own Stream α
(the developer toolchain).
## What Pyde is
Post-quantum L1 (FALCON-512 sigs, Kyber-768 threshold encryption,
Poseidon2+Blake3 hashing). Mysticeti-style consensus with 128/85 quorum
and sub-second commits. WASM execution via wasmtime. MEV-resistant
by structure. Pre-mainnet, solo-founder-led (zarah). Workspace at the
`pyde-net/` polyrepo root.
## Your stream
Implement the `otigen` developer toolchain binary in a fresh repo
`pyde-net/otigen`. The toolchain:
- Reads `otigen.toml` configs
- Validates compiled `.wasm` artifacts against the Host Function ABI
- Injects a `pyde.abi` custom section into the WASM
- Signs and submits deploy / upgrade / lifecycle transactions
- Manages FALCON-512 keystores
- Offers an interactive REPL
## Authoritative specs
In priority order:
1. `pyde-book/src/companion/OTIGEN_BINARY_SPEC.md` — your canonical
spec (>820 lines). Every command, every config key,
every validation rule.
2. `pyde-book/src/companion/HOST_FN_ABI_SPEC.md` — the chain-facing
ABI you validate WASM modules against.
3. `pyde-book/src/companion/OTIGEN_TEST_SPEC.md` — canonical spec for
`otigen test`: TOML schema, name resolution (account → Blake3 addr,
field → Poseidon2 slot), cheatcode catalogue, mock host-fn
behaviour, limitations. Implements as the `otigen-test` crate.
4. `pyde-book/src/companion/IMPLEMENTATION_PLAN.md` — coordination
doc; defines your scope + how to coordinate with streams β and γ.
5. `pyde-book/src/companion/PARACHAIN_DESIGN.md` — parachain-specific
extension surface (parachain deploy + cross-parachain messaging).
6. `pyde-book/src/chapters/05-otigen-toolchain.md` — narrative
overview (lighter; specs above are canonical).
7. `pyde-book/src/chapters/11-account-model.md` — transaction wire
format, address derivation.
## Constraints
- **No AI attribution anywhere** (commits, code, PRs). Work reads
as zarah's own.
- **No per-language SDK shipping with otigen** — by design (see
PARACHAIN_DESIGN.md §10). Canonical example contracts only.
- **otigen does NOT invoke language compilers.** Author runs
`cargo build` / `npx asc` / etc. themselves; otigen post-processes
the resulting `.wasm`.
- **Apache-2.0 license**, clippy clean, fmt applied, no `unwrap()`
on untrusted paths.
- **The `pyde.abi` custom section is the canonical ABI** — chain
stores only the `.wasm`; the section travels with the code.
## Setup
1. Check `/pyde-net/otigen/` exists locally and on
`github.com/pyde-net/otigen`. Create both if not.
2. Initialize a Rust workspace.
3. Sub-crates: `otigen-cli`, `otigen-toml`, `otigen-abi`,
`otigen-rpc`, `otigen-wallet`. (Names suggested; adjust if you
have a better structure.)
4. Depend on `pyde-crypto` (sibling polyrepo) for FALCON-512,
Argon2id, AES-256-GCM, Borsh.
## First milestone
`otigen.toml` parsing + the `otigen build` validation pipeline
(spec §4 + §3.2). This is the foundation everything else builds on:
1. Parse `otigen.toml` with full schema validation (use `serde` + `toml`).
2. Locate the compiled `.wasm` at the declared path.
3. Walk the WASM via `wasmparser` and run every check in spec §3.2.
4. Build the `ContractAbi` struct from parsed config + WASM exports.
5. Borsh-encode + inject `pyde.abi` custom section via `wasm-encoder`.
6. Write the deploy bundle to `./artifacts/<name>.bundle/`.
7. Test against a canonical example Rust hello-world contract.
## Coordination
- You're independent of streams β and γ; only common dependency is
the locked HOST_FN_ABI_SPEC.
- Open issues on `pyde-net/otigen` for spec ambiguity; ping zarah.
- When you reach `otigen deploy`, you'll need a devnet to test
against. By then streams β + γ should have one running.
## First action
Read OTIGEN_BINARY_SPEC.md end-to-end. Read chapter 5 for context.
Verify the workspace setup. Begin first-milestone work.
7.2 Stream β — Engine Execution session prompt
# Pyde Session β — Engine Execution Layer
You're joining the Pyde Layer 1 blockchain project. Three parallel
implementation streams are running concurrently; you own Stream β
(the execution layer of the engine).
## What Pyde is
Post-quantum L1 (FALCON-512 sigs, Kyber-768 threshold encryption,
Poseidon2+Blake3 hashing). Mysticeti-style consensus with 128/85 quorum
and sub-second commits. WASM execution via wasmtime. MEV-resistant
by structure. Pre-mainnet, solo-founder-led (zarah). Workspace at the
`pyde-net/` polyrepo root.
## Your stream
Implement the execution side of `pyde-net/engine`. Crates you own:
- `account` — 32-byte addresses, AuthKeys enum, 16-slot nonce window
- `state` — JMT dual-hash, state_cf + jmt_cf + events_cf×3,
PIPs 2/3/4 (clustered keys, prefetch, write-back cache),
snapshot generation
- `tx` — transaction types, canonical hashing, gas accounting,
deploy/upgrade/lifecycle handlers
- `wasm-exec` — wasmtime config, every host function from
HOST_FN_ABI_SPEC §7-§8, module cache, fuel-to-gas mapping,
per-tx overlay execution model
- `mempool` — FALCON verify, validation rules, gossip admission
You work on branch `execution-side` of `pyde-net/engine`.
Stream γ (consensus side) works on branch `consensus-side` in the
same repo. **Do not touch γ's crates** (`consensus`, `net`, `dkg`,
`slashing`, `node`). Communicate cross-stream needs via GitHub
issues; do not edit interfaces or shared types unilaterally.
## Authoritative specs
In priority order:
1. `pyde-book/src/companion/HOST_FN_ABI_SPEC.md` — every host
function you implement. 2,154 lines, 18 sections. The chain side
of the WASM ⇄ chain boundary.
2. `pyde-book/src/companion/IMPLEMENTATION_PLAN.md` — coordination
doc (this stream's scope, crate ownership, branching protocol,
interface contracts).
3. `pyde-book/src/chapters/04-state-model.md` — JMT, two-table
architecture, events_cf, PIP-2/3/4.
4. `pyde-book/src/chapters/03-virtual-machine.md` — execution
layer architecture, per-tx overlay, native vs WASM tx types.
5. `pyde-book/src/chapters/11-account-model.md` — account types,
address derivation, tx wire format, nonce window.
6. `pyde-book/src/chapters/10-gas-and-fee-model.md` — gas
accounting, no-refund policy, EIP-1559 base fee.
7. `pyde-net/pips/pip-0002` (clustered keys), `pip-0003` (prefetch),
`pip-0004` (write-back cache).
## Constraints
- **No AI attribution anywhere** (commits, code, PRs).
- **Apache-2.0 license**, clippy clean, fmt applied, no `unwrap()`
on untrusted-input paths.
- Use the frozen `types` and `interfaces` crates (in MC-0) — do
NOT change them without a coordinated PR.
- `mempool` is yours; consensus reads from it via the
`MempoolView` trait. Do not let γ touch your crates.
- `wasm-exec` implements the host functions; the engine
registers them with wasmtime's `Linker`. Authoritative gas
costs in spec §10. Authoritative validation rules in spec §3.7.
## Setup
1. `pyde-net/engine` repo exists post-MC-0; clone it locally.
2. Check out the `execution-side` branch.
3. Verify the workspace skeleton with stub crates compiles.
## First milestone
Implement the `account` + `state` crates with full functionality
(no WASM execution yet — that comes next). Key deliverables:
- Address derivation: `Poseidon2(falcon_pubkey)` → 32-byte address.
- `AuthKeys` enum with `Single`, `MultiSig`, `Programmable`
variants (Programmable v2-reserved per ch 11 §11.5).
- 16-slot nonce window per account.
- JMT dual-hash (Blake3 + Poseidon2) state tree.
- Two-table architecture (`state_cf` + `jmt_cf`).
- Atomic WriteBatch commits.
- Implement the `StateView` and `StateMutator` traits from the
`interfaces` crate.
- Test against the mock implementations in interfaces/.
Once `account` + `state` are solid, move to `tx`, then `wasm-exec`,
then `mempool`.
## Coordination
- Open issues on `pyde-net/engine` for design questions.
- Merge to `main` weekly minimum after CI green + reviewer LGTM.
- Tag milestones: `phase-1-β-milestone-N` (1 = state, 2 = wasm-exec
basics, 3 = full host fn catalog, etc.).
- Cross-stream blockers: tag both stream agents in the issue.
## First action
Read HOST_FN_ABI_SPEC.md end-to-end. Read IMPLEMENTATION_PLAN.md.
Read chapters 03, 04, 11, 10. Verify branch + workspace state.
Begin with `state` crate (foundational; everything else builds on it).
7.3 Stream γ — Engine Consensus + Network session prompt
# Pyde Session γ — Engine Consensus + Network Layer
You're joining the Pyde Layer 1 blockchain project. Three parallel
implementation streams are running concurrently; you own Stream γ
(the consensus + network + node binary side of the engine).
## What Pyde is
Post-quantum L1 (FALCON-512 sigs, Kyber-768 threshold encryption,
Poseidon2+Blake3 hashing). Mysticeti-style consensus with 128/85 quorum
and sub-second commits. WASM execution via wasmtime. MEV-resistant
by structure. Pre-mainnet, solo-founder-led (zarah). Workspace at the
`pyde-net/` polyrepo root.
## Your stream
Implement the consensus + networking side of `pyde-net/engine`.
Crates you own:
- `consensus` — Mysticeti DAG, vertex/anchor/wave logic, BFS subdag
walk, slashing evidence collection, equivocation detection,
missing-vertex fetch, threshold-decryption coordination
- `net` — libp2p + QUIC + Gossipsub, peer discovery (layered, no
DHT), sentry-node pattern, vertex-fetch protocol
- `dkg` — Pedersen DKG protocol (or thin wrapper if it lands in
pyde-crypto first)
- `slashing` — validator state machine, 10-offense catalog,
slashing escrow, jail mechanics, reward distribution
- `node` — the binary, JSON-RPC server, validator role,
`consensus_store` with `set_sync(true)`, persistence,
`panic = "abort"` on persist failure
You work on branch `consensus-side` of `pyde-net/engine`.
Stream β (execution side) works on branch `execution-side` in the
same repo. **Do not touch β's crates** (`account`, `state`, `tx`,
`wasm-exec`, `mempool`). Read from them via the locked
`interfaces` traits. Communicate cross-stream needs via GitHub
issues; do not edit interfaces or shared types unilaterally.
You own the `node` crate — it wires everything together at
integration time (MC-2).
## Authoritative specs
In priority order:
1. `pyde-book/src/companion/IMPLEMENTATION_PLAN.md` — coordination
doc (your scope, crate ownership, branching protocol, interface
contracts).
2. `pyde-book/src/chapters/06-consensus.md` — Mysticeti DAG,
anchor selection, wave commit, BFS subdag walk, threshold
decryption ceremony, HardFinalityCert.
3. `pyde-book/src/companion/SLASHING.md` — full 10-offense catalog.
4. `pyde-book/src/companion/VALIDATOR_LIFECYCLE.md` —
registration, bonding, unbonding, jail mechanics, key rotation.
5. `pyde-book/src/companion/STATE_SYNC.md` — snapshot mechanics,
chain-of-trust, weak-subjectivity checkpoints.
6. `pyde-book/src/companion/CHAIN_HALT.md` — halt detection, 5
recovery paths, bounded rollback.
7. `pyde-book/src/companion/NETWORK_PROTOCOL.md` — libp2p config,
Gossipsub topics, peer scoring, sentry pattern.
8. `pyde-book/src/chapters/12-networking.md` — networking detail.
9. `pyde-book/src/chapters/08-cryptography.md` — DKG, threshold
decryption, VRF (your consumer; pyde-crypto is the impl).
10. `pyde-book/src/companion/THREAT_MODEL.md` — security context.
## Constraints
- **No AI attribution anywhere** (commits, code, PRs).
- **Apache-2.0 license**, clippy clean, fmt applied, no `unwrap()`
on untrusted-input paths.
- Use the frozen `types` and `interfaces` crates from MC-0 — do
NOT change them without a coordinated PR.
- `consensus` reads txs from `mempool` via `MempoolView` (β owns
mempool). It invokes execution via `Executor` trait (β owns
wasm-exec). Don't reach into β's crates directly.
- All consensus-store writes use `WriteOptions::set_sync(true)`
per Chapter 16 §16.12. Persist failure = `panic = "abort"`.
## Setup
1. `pyde-net/engine` repo exists post-MC-0; clone it locally.
2. Check out the `consensus-side` branch.
3. Verify the workspace skeleton with stub crates compiles.
## First milestone
Implement the `consensus` crate with the Mysticeti DAG core:
- Vertex structure (round, member_id, parent_refs, batch_refs,
state_root_sigs, decryption_shares, prev_anchor_attestation, sig).
- Local DAG view (in-memory graph with vertex insertion + lookup).
- Round advancement (peer-attestation triggered, data-driven —
NOT clock-driven).
- Anchor selection: `Hash(beacon, round, prev_state_root) mod 128`.
- BFS subdag walk + canonical sort (round asc, member_id asc,
batch_list_order).
- Missing-vertex fetch protocol (async pull from peers).
- Anchor-skip handling (when anchor vertex absent).
- Test in isolation using `MockStateView`, `MockMempool`,
`MockNetwork` from the `interfaces` crate.
Then move to `net` (libp2p + Gossipsub topics), then `slashing`,
then wire it all up in `node`.
## Coordination
- Open issues on `pyde-net/engine` for design questions.
- Merge to `main` weekly minimum after CI green + reviewer LGTM.
- Tag milestones: `phase-1-γ-milestone-N` (1 = consensus core,
2 = network, 3 = slashing + lifecycle, 4 = node binary).
- Cross-stream blockers: tag both stream agents in the issue.
- You own integration: when MC-2 begins, you drive the merge +
devnet bring-up.
## First action
Read IMPLEMENTATION_PLAN.md. Read chapter 6 end-to-end (the spec
is dense and the BFS / anchor / threshold-decryption mechanics
are subtle). Read SLASHING.md + VALIDATOR_LIFECYCLE.md +
CHAIN_HALT.md. Verify branch + workspace state. Begin with
`consensus` crate (foundational for everything else in γ).
8. Risks + mitigations
| Risk | Severity | Mitigation |
|---|---|---|
Interface drift during MC-1 — β or γ realizes a needed change to interfaces mid-implementation | High | Both sides write tests against the locked traits early. If a change is genuinely required, both sides + main session co-sign the PR. |
types crate creep — new fields added ad-hoc as implementation reveals needs | High | All type additions are PRs against types crate, reviewed by both other streams. Pre-MC-1 we lock the "v1 type set" via thorough walk-through. |
| One stream lags substantially | Medium | Weekly merges to main make lag visible early. If γ lags, β still ships; integration happens when both are ready. No artificial gating. |
| Spec ambiguity blocks implementation | Medium | Open a PR against the relevant spec in pyde-net/pyde-book; both streams read updated spec from there. Treat spec as the contract. |
| Cross-stream blocker not surfaced | Medium | GitHub issue tags both stream agents; weekly merge reviews catch silent blockers. |
| Integration (MC-2) bigger than expected | Medium | γ owns the node crate from day one — eliminates a "who integrates" question. β provides clean trait implementations + tests that γ wires in. |
| Stream α blocked waiting on devnet | Low | α first milestone (otigen build) needs no chain; second milestone (otigen deploy) is when chain matters. By then β+γ should have devnet running. If not, α can mock-deploy against a stub RPC. |
9. Glossary of agreements
Quick reference for things the implementation must hold to:
typescrate is FROZEN at end of MC-0. No additions without coordinated PR.interfacescrate is FROZEN at end of MC-0. Same rule.- No co-ownership of crates. Each crate has one owning stream. Period.
- Weekly merges minimum. No long-lived branches diverging silently.
- No AI attribution. Anywhere. Per
no_ai_attributionmemory. - Apache-2.0 + clippy-clean + no untrusted
unwrap(). CI enforces. - Specs are authoritative. When code and spec disagree, the spec is right; either fix the code or update the spec via PR.
- Multi-topic events native at v1. Not a v2 deferral (per recent locked decision).
- View calls are free (RPC pyde_call AND on-chain cross_call_static). Bounded by
VIEW_FUEL_CAP. - Gas refunds: zero in v1. No exceptions.
10. References
- HOST_FN_ABI_SPEC.md — chain-facing ABI
- OTIGEN_BINARY_SPEC.md — toolchain spec
- OTIGEN_TEST_SPEC.md — contract behaviour test framework
- PARACHAIN_DESIGN.md — parachain framework
- STATE_SYNC.md, CHAIN_HALT.md, SLASHING.md, VALIDATOR_LIFECYCLE.md, NETWORK_PROTOCOL.md, THREAT_MODEL.md, FAILURE_SCENARIOS.md, PERFORMANCE_HARNESS.md — operational specs
- The 20 book chapters + 4 PIPs — full design
Document version: 0.1 (draft for v1 mainnet)
License: Apache-2.0 + CC BY-SA 4.0 (per repository root)
Pyde Tokenomics
The PYDE token is the native asset of the Pyde blockchain. It is used for: gas payment, validator staking, governance signaling, and parachain operator bonds.
Total Supply & Genesis
- Total genesis supply: 1,000,000,000 PYDE
- Decimal places: 9 (1 PYDE = 10^9 quanta — see Chapter 14 for the full denomination ladder)
- Smallest unit: 1 quanta = 10^-9 PYDE
Initial Distribution (v1)
| Allocation | Amount | % | Vesting |
|---|---|---|---|
| Validator rewards pool | 200,000,000 | 20% | Released proportionally over 4 years via inflation |
| Treasury (multisig-controlled) | 150,000,000 | 15% | Released via governance proposals |
| Ecosystem grants | 100,000,000 | 10% | 4-year cliff for grantees |
| Public sale | 200,000,000 | 20% | Released at genesis to public buyers |
| Founders & early contributors | 150,000,000 | 15% | 4-year vesting, 1-year cliff |
| Investors | 200,000,000 | 20% | 4-year vesting, 1-year cliff |
Numbers above are illustrative starting points; final distribution requires legal review and stakeholder negotiation.
Inflation Schedule
| Year | Inflation rate | New PYDE minted |
|---|---|---|
| 1 | 5% | 50M |
| 2 | 3% | ~30M (compounding) |
| 3 | 2% | ~21M |
| 4+ | 1% (fixed) | ~10M/year thereafter |
Rationale: front-loaded inflation rewards early validators; fixed 1% tail provides long-term security budget without unbounded dilution.
Inflation accrues to the reward pool, distributed per the same rule as the fee share (see below).
Fee Model (EIP-1559 Style)
Every transaction has:
- Base fee: dynamically adjusted per block (EIP-1559 mechanism, target 50% block utilization)
- No priority tip. The encrypted mempool eliminates the information asymmetry that priority fees price. Priority would re-introduce ordering exploitation.
- Combined gas: for
cross_call!invocations (post-mainnet), Pyde-side + parachain-side gas billed in one transaction
Block Elasticity
- Target gas limit per block: 400M gas
- Maximum (4× elastic): 1.6B gas
- Base fee adjusts up when blocks are >50% full, down when <50%
- Adjustment factor: ±12.5% per block (EIP-1559 standard)
Per-Transaction Fee Flow
For every transaction's base fee:
100% of base_fee
├── 70% burned (deflationary pressure)
├── 10% to treasury (multisig-controlled)
└── 20% to the reward pool
├── 70% activity-weighted across active committee (= 14% of total)
│ • Vertices certified by ≥85 peers
│ • Batches included in committed waves (× tx count)
│ • Decryption shares submitted on time
│ • Anchor selections (uptime-correlated)
└── 30% flat across full stake pool (= 6% of total)
(every staked validator earns the base; activity bonus is layered on for those currently on the committee)
Plus inflation issuance (also flowing into the reward pool) distributed by the same rule.
Validator Staking
Bond Requirements
Single-tier staking:
- Minimum: 10,000 PYDE (
MIN_VALIDATOR_STAKE) — any validator meeting this threshold enters the pool from which the 128-member active committee is uniformly randomly selected each epoch - Maximum validators per operator: 3 (anti-Sybil cap, enforced on operator identity)
- Bonding period: 1 epoch (~3 hours) before active
- Unbonding period: 30 days (must exceed the 21-day safety evidence freshness window)
There is no separate "committee tier" with a higher floor. Pyde relies on threshold encryption + operator-identity cap + slashing for Sybil resistance, not on stake-size economics (see Chapter 16 §16.4 for the full security argument).
Staking Yield Estimate
Assume:
- 50% of supply staked → 500M PYDE
- Year 1 inflation: 50M PYDE → distributed to validators
- Activity rewards from fees: scales with chain usage
Estimated yield year 1:
Inflation share: 50M / 500M = 10%
Fee share: depends on chain activity
At low utilization: ~10-12% APY
At moderate utilization (target): ~12-15% APY
At high utilization: ~15-20% APY
Specific yields depend on actual network activity. Numbers above are illustrative; actual yields will be observable post-launch.
Active-Committee vs Awaiting-Selection Earnings
Every staked validator earns from the same pool; the difference is in the activity-weighted bonus while serving on the active committee.
| Status | Earnings Source |
|---|---|
| Validator on active committee | Base stake × uptime share of reward pool + activity-weighted committee bonus (vertices certified, batches included, anchor selections) + inflation share |
| Validator awaiting selection | Base stake × uptime share of reward pool + inflation share (no committee bonus until selected) |
Committee participation is per-epoch; over time, every validator qualifying for the pool will rotate onto the active committee proportionally and accrue activity bonuses then.
Slashing Economics
Slashing penalties (see SLASHING.md for full catalog):
| Offense | First instance | Max |
|---|---|---|
| Equivocation | 10% | 50% (correlation/repeat) |
| Bad state-root | 10% | 50% |
| Downtime | 0.05%/round | 10%/epoch |
Distribution of slashed amounts (safety offenses):
- 50% burned (irrecoverable, hurts attacker economics)
- 30% to treasury
- 20% to reporter (incentivizes monitoring)
Distribution of slashed amounts (liveness offenses):
- 100% burned (no reporter incentive needed; protocol auto-detects)
Treasury
The treasury accrues from:
- 10% of all transaction base fees
- Treasury portion of slashing (30% from safety offenses)
- Inflation allocation (if any portion designated)
Treasury spending is gated by M-of-N FALCON multisig (7-of-12 recommended) and is restricted to:
- Public goods grants (developer tools, audits, infra)
- Bug bounty payouts
- Emergency response (rare)
- Other purposes ratified by PIP (Pyde Improvement Proposal)
The treasury cannot be unilaterally drained — public PIPs + multisig threshold + 30-day-bounded emergency pause provide checks.
Parachain Operator Economics (Post-Mainnet)
Parachain operators stake PYDE as their bond and earn from the combined gas of every cross_call! invocation. The split is:
- 70% to parachain operator(s) providing the cross-chain service
- 20% to the Pyde-side reward pool (for executing the originating transaction)
- 10% burned (consistent with main fee model)
Parachain operators face their own slashing for misbehavior (incorrect responses, downtime), creating staked-honesty guarantees comparable to validators.
Token Velocity & Use
PYDE is intended to be used for transactions, staking, and bond, not held purely as speculative store-of-value. Mechanisms to encourage utility:
- Gas burn (70%): every transaction reduces supply, creating deflationary pressure when network usage is high
- Validator bond locking: 10K PYDE per validator slot, locked during operation
- Treasury spending: continually deploys PYDE into the ecosystem
- No priority tips: removes the speculative auction layer that creates token-velocity drag
Long-Term Sustainability
Post year-4, supply economics are:
- Inflation: ~1% per year (~10M PYDE)
- Burn rate: depends on usage; at sustained moderate usage with a mixed workload, estimated ~30-100M PYDE/year burned
At sustained moderate usage, the chain is net deflationary (burn > inflation). At low usage, slight inflation maintains validator security budget. At very high usage, deflationary pressure may eventually require fee structure adjustments (governance decision).
Open Questions
- Initial distribution percentages: above are illustrative; final allocations need legal + stakeholder negotiation.
- Investor terms: lockup, vesting, and post-vesting governance rights are open design questions.
- Treasury governance specifics: which categories of spending require which multisig thresholds — to be detailed in governance PIP.
- Parachain reward split: 70/20/10 above is starting point; may adjust based on operator economics post-mainnet.
References
- Fee flow: see WHITEPAPER.md §12
- Slashing details: see SLASHING.md
- Validator lifecycle: see VALIDATOR_LIFECYCLE.md
Version 0.1
Pyde Brand Reference
Version 0.1 · canonical brand guidance for the Pyde wordmark, glyph, and visual system.
This page is the source of truth for anyone (designers, contributors, integrators, ecosystem teams) using the Pyde name or mark. If you are about to make a logo lockup, a poster, a sticker, or a third-party landing page, read this first.
For the story behind the name and the mark, see How Pyde Works → What's in a name.
1. The name
Pyde — pronounced pied (rhymes with tide).
| Form | Use |
|---|---|
Pyde | Default, sentence-case. Use this almost everywhere — headings, prose, marketing copy. |
pyde | Lowercase in URLs, handles, file names, code identifiers (pyde-net, pyde.network). The X handle is @pydenet (the available short form). |
PYDE | Uppercase only when referring to the token / unit of account (100 PYDE, gas paid in PYDE). |
PYDE NETWORK (all caps) | Not used. Only in occasional design-led headings if it serves a layout, never in body copy. |
Do not:
- Write
pYde,PyDe,pydE, or any other mixed-case treatment. - Translate the name. It is
Pydein every language. - Spell it out as an acronym (
Programmable Yield Decentralized Engineand other backronyms). The name is not an acronym. Drop it from any third-party marketing that suggests it is.
Sentence patterns:
✓ Pyde is a sovereign L1.
✓ Send 100 PYDE to alice.pyde.
✓ pyde.network/docs
✗ The PYDE Network announces…
✗ pYde launches mainnet
2. The mark (glyph)
The mark is based on atomic structure — a nucleus and its orbital. Not a trend, not network imagery, not decorative. It looks like a physical law.
Anatomy:
- The vertical form is the core. Dense, gravitational, everything pulls toward it. Pyde is monolithic — consensus and execution in one place. Wide at the poles, compressed at the center: finality under pressure. Stress-tested and held.
- The circle to the right is in orbit. Independent, in motion, but bound to the core by an invisible force. External chains, bridges, light clients, portable finality certificates — they orbit. They are verified, not trusted.
- The two are separate on purpose. Related but sovereign. The composition is asymmetric — the orbital sits to the upper-right. Do not mirror, balance, or duplicate it. The core is fixed; the orbital can be anywhere.
Geometry:
- The orbital's diameter is about
0.40×the core's widest width. - The orbital's centre sits about
0.55×the core's height from the top, offset right by about0.85×the core's widest radius.
Guidance, not pixel rules. To recreate, eyeball against assets/logo.png.
3. Lockups
| Lockup | Use |
|---|---|
| Mark alone | Favicons, app icons, profile pictures, social avatars, watermarks, very small footprints. Default for any context under 32×32 px. |
| Mark + wordmark, horizontal | Website headers, presentation cover slides, partnership materials. Wordmark sits to the right of the mark, baseline-aligned to the mark's vertical centre. Space between mark and wordmark = 1.0× the mark's widest radius. |
| Mark + wordmark, vertical | Posters, stickers, merchandise. Wordmark sits below the mark, centered. Vertical space = 0.6× mark height. |
Clear space: the mark must always have clear space around it equal to 0.5× the mark's widest radius. No other graphic element (text, image, border) intrudes into this clear space.
Minimum size: the mark must not be rendered below 16×16 px. At sizes under 32×32 px, do not pair it with the wordmark.
4. Colour
The Pyde palette is black and white, with shades. Nothing more.
Restrained, calm, subtle — the visual posture matches the technical posture. The brand is meant to feel like a physical law: present, quiet, not asking for attention. Color noise doesn't belong here. The protocol is the product, not the palette.
| Token | Hex | Role |
|---|---|---|
--pyde-ink | #0d1117 | Primary dark — backgrounds, dark-theme surfaces, default body text in light mode. |
--pyde-shadow | #2a2f36 | Dark elevated — elevated surfaces on dark backgrounds, code-block fills. |
--pyde-mist | #7a8590 | Mid-gray — muted labels, captions, dividers. |
--pyde-veil | #e1e4ea | Light elevated — soft surfaces on light backgrounds, subtle dividers. |
--pyde-paper | #f7f8fa | Primary light — backgrounds in light mode, default body text in dark mode. |
These five grayscale tokens carry the entire brand. No accent palette. Color is not part of the brand.
Mark colouring rules:
- The mark is grayscale. The canonical rendering is the gradient version in
assets/logo.png. - A solid-black or solid-white version of the mark is acceptable for monochrome contexts (engraving, single-colour print, dark-on-light printing).
- Never recolor the mark. Not for theming, not for events, not for partnerships, not for special occasions.
Why grayscale only:
- The brand should feel like a physical law: present, calm, derived not designed.
- The mark is grayscale (a nucleus and its orbital, see §2). The palette mirrors that posture.
- A restrained palette stands out in a sea of colorful chains. Discipline reads as confidence.
- Color is reserved for one purpose only: the existing factory illustration (see §6), which predates this discipline and serves as a didactic diagram, not as brand surface.
5. Typography
Pyde uses system fonts. No custom typeface ships with the brand.
| Context | Font stack |
|---|---|
| Body, UI, code | -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif |
| Monospace (code, hashes, addresses) | ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace |
Why system fonts:
- Zero load time, perfect cross-platform rendering.
- Accessibility-first: respects the user's font-size preferences.
- No licensing complications for downstream community use.
- Consistent with Pyde's minimalism — the protocol is the product, not the typography.
When the brand needs a "voice" beyond system fonts (a presentation cover, a marketing illustration), use Inter (Bold or Black weight) for the wordmark only. Body type stays system.
6. The factory illustration system
The factory metaphor (see How Pyde Works) is a teaching illustration, not a brand surface. The animated SVG at src/assets/factory-loop.svg predates the grayscale-only discipline (§4) and retains its original color cues — droplets for transactions, an amber flash for wave commit, a green pillar for state, a gold lock for threshold encryption — as didactic shortcuts that help readers visualize Pyde mechanics at a glance.
These colors live in the animation only. They are not brand tokens. New illustrations default to the §4 grayscale palette; if differentiation beyond gray is needed, prefer pattern, opacity, line weight, or texture over color.
When making a new illustration:
- Use the §4 grayscale tokens.
- Lines are
1.4pxfor structural elements,0.6pxfor fine detail. - Corners are slightly rounded (
rx="2"is the default). - Animations loop on a 3-second cycle (matching the factory loop's tempo).
Diagrams that explain Pyde mechanics may reuse the factory's visual vocabulary in grayscale: droplets for transactions, boxes for vertices, pillars for state, exhaust for eviction, a flash for wave commit.
7. Voice and tone
This is the brand's voice — apply it to any official copy or external comms.
| Quality | Means |
|---|---|
| Direct | Short sentences. Active voice. Avoid "we are excited to announce." Just announce. |
| Honest | Numbers are real numbers. "Throughput is whatever the multi-region harness measures on commodity hardware — published only once it's measured" is a Pyde-voice sentence; "Pyde achieves limitless throughput" is not. |
| Specific | "Mysticeti-style consensus with 128-validator committee, FALCON-512 signatures" beats "next-generation consensus." Name the thing. |
| Unpretentious | No "L1 of L1s," no "ushering in a new era." If a competitor would write it, don't. |
| Curious | When something is hard or undecided, say so. The audience is technical; treating them as adults builds trust. |
Examples:
✓ Pyde commits in waves through a 128-validator Mysticeti-style consensus. Encrypted transactions stay sealed until the wave commits.
✓ v1 ships realistic numbers, not aspirational ones. The throughput target is published only once the multi-region harness measures it on commodity hardware.
✗ Pyde is the world's first post-quantum, MEV-resistant, infinitely scalable Web3 platform of the future.
✗ We are revolutionizing how the world thinks about blockchain.
8. Asset inventory
The canonical brand asset directory is pyde-book/src/assets/ (also mirrored at pyde-book/assets/).
| File | Purpose |
|---|---|
logo.png | Canonical full-colour grayscale mark, 500×500 px. Default for digital use. |
factory-loop.svg | Animated illustration of the Pyde transaction lifecycle. The brand's visual vocabulary defined in motion. |
Pending (post-launch designer handoff):
logo.svg— vector source of the mark (currently only PNG exists).wordmark.svg— vector wordmark in Inter Bold.mark-monochrome.svg— single-colour version for engraving, single-colour print.social-card-default.png— Open Graph + Twitter Card default image.presentation-template.pptx/.key— slide deck template with the brand applied.
9. Third-party use
Community projects, ecosystem partners, and individuals are welcome to use the Pyde mark and name to refer to Pyde, subject to these rules:
Allowed without permission:
- Use the name "Pyde" to describe Pyde, in factual reference (news articles, tutorials, code documentation, third-party tooling that integrates with Pyde).
- Display the mark to indicate compatibility or integration ("works with Pyde," "deploys on Pyde").
- Reuse
assets/logo.pngandassets/factory-loop.svgat original aspect ratios.
Not allowed without permission:
- Use the name "Pyde" in your product name (
PydeWallet,PydeDeFi) in a way that implies official endorsement. - Modify the mark (recolour, distort, add elements, reshape).
- Use the mark on merchandise sold for profit at scale.
- Imply official affiliation with the Pyde Foundation when none exists.
For anything ambiguous, default to asking. There is no formal trademark registration at v1; the goodwill is community-held.
10. This document evolves
The brand is young. This document is a snapshot, not a contract. As Pyde matures and a dedicated designer joins, expect:
- A formal logo grid + construction guidance.
- A full type system (likely a custom display face for the wordmark).
- A motion-design spec beyond the factory loop.
- A photography / illustration direction for marketing.
When that work lands, this document gets revised and the version number bumps.
Document version: 0.1
License: See repository root
Chapter 21: Appendix
Reference material from across the book in one place: the glossary, the constants tables, the discriminator registry, the JSON-RPC method index, and the post-mainnet plan.
A. Glossary
| Term | Definition |
|---|---|
| Pyde | The post-quantum L1 blockchain. Name of the protocol, the network, and the binary. |
| PYDE | The native token. 1 PYDE = 10^9 quanta. |
| otigen | Pyde's developer toolchain (the binary). Scaffolds projects, builds WASM artifacts, deploys, manages wallets. Name carried forward from the retired Otigen language. |
| WASM | WebAssembly. Pyde's execution layer; smart contracts and parachains compile to WASM and execute under wasmtime. |
| wasmtime | The WebAssembly runtime used by Pyde. Bytecode Alliance project, production-vetted at Microsoft / Fastly / Shopify. |
| Host Function ABI | The stable interface contracts use to interact with chain state (sload, sstore, transfer, threshold crypto, hashing, cross_call, etc.). See the Host Function ABI spec. |
| Cranelift | The code generator used by wasmtime for ahead-of-time WASM-to-native compilation. |
| Otigen (retired) | Was Pyde's domain-specific smart-contract language. Retired in the WASM pivot; see The Pivot preface. The Otigen Book is preserved as a historical artifact. |
| JMT | Jellyfish Merkle Tree. The state commitment structure (radix-16, path-compressed). |
| Blake3 | Fast bitwise hash. Used for JMT internals, batch hashes, vertex hashes, gossip de-dup. |
| Poseidon2 | Algebraic hash over the Goldilocks field. State root commit, addresses, MAC, VRF, ZK-bearing paths. |
| FALCON-512 | NIST FIPS 206 post-quantum signature scheme. ~666-byte sigs, 897-byte pks. |
| Kyber-768 | NIST FIPS 203 post-quantum KEM. P2P session keys and threshold mempool. |
| Threshold encryption | Mempool encryption such that any 85 of 128 committee members combine to decrypt. |
| PSS | Proactive Secret Sharing — refresh key shares without changing the public key. |
| DKG | Distributed Key Generation. Pedersen DKG ceremony each epoch for threshold pubkey. |
| VRF | Verifiable Random Function. Lattice-based; built from FALCON + Poseidon2. |
| Mysticeti | The DAG-based consensus protocol Pyde uses (post-2026 pivot, formerly HotStuff). |
| DAG | Directed Acyclic Graph. Every round, each committee member produces a vertex; parents must be strictly prior rounds. |
| Vertex | A committee member's per-round output: batch refs + parent refs + state-root sigs + decryption shares + FALCON sig. |
| Round | A ~150 ms DAG cycle. Each member produces one vertex per round. |
| Wave | The Mysticeti commit unit. Anchor at round R+3 commits the subdag rooted at round R. |
| Anchor | Deterministically-selected committee member whose round-R vertex commits the wave. Hash(beacon, round, prev_state_root) mod 128. |
| Worker / Primary | Narwhal pattern: workers gossip tx batches, primary produces vertices and runs consensus. |
| HardFinalityCert | ≥ 85 FALCON sigs over (wave_id, blake3_state_root, poseidon2_state_root). |
| Committee | The 128 active validators per epoch. Equal vote weight; uniform random selection. |
| Epoch | ~3 hours of waves. PSS resharing fires at epoch boundary. |
| Validator | Node staking ≥ MIN_VALIDATOR_STAKE (10,000 PYDE). Single tier — uniform-random committee selection picks 128 from the eligible pool each epoch. |
| Full node | Node that executes waves and serves RPC, but does not stake. |
| MEV | Maximal Extractable Value. The MEV class is structurally closed in Pyde. |
| Encrypted mempool | Optional Kyber-encrypted submission. Decryption deferred until after DAG anchor commit. |
| Commit-before-reveal | DAG anchor commits canonical ordering before threshold-decryption shares are released. |
| Block-STM scheduler | Execution model: uniform optimistic parallel execution through an MVCC layer; conflicts detected at validation, losers re-execute until fixpoint. Access lists from pyde_simulateTransaction drive PIP-3 multiget prefetch but never partition the wave. |
| Sentry node | Public-facing proxy in front of a committee validator. Hides validator's real IP. |
| Treasury | The system account at Poseidon2("pyde-treasury"). Spent via on-chain multisig. |
| PIP | Pyde Improvement Proposal. Off-chain documents that drive code changes. |
| Multisig signers | The on-chain set authorized to spend the treasury (MULTISIG_SIGNERS). |
| Emergency pause | Multisig-authorized halt of non-Resume txs; max 30 days, auto-expiring. |
| Hard halt | Automatic chain halt on detected safety violation (state root divergence, equivocation cluster). |
| Weak-subjectivity checkpoint | Hard-finalized commit (wave_id + state_root + committee FALCON sigs) that a fresh node trusts to anchor sync. |
| Quanta | Smallest PYDE denomination. 1 PYDE = 10^9 quanta. |
| Access list | Per-tx declaration of state slots the tx will read or write. |
| Nonce window | 16-slot bitmap of in-flight nonces per account. |
| Gas tank | Per-account dedicated balance for sponsoring user transactions. |
| Paymaster | A contract that pays gas on behalf of a user, with custom validation logic. |
| Parachain operator | Permissionless v2 actor who stakes PYDE, fulfills cross_call! to other chains, earns gas fees. |
B. Network Constants
| Constant | Value | Where |
|---|---|---|
ROUND_PERIOD_MS | 150 (DAG round cadence) | consensus/round.rs |
COMMIT_TARGET_MS | 500 (median commit) | consensus/commit.rs |
EPOCH_LENGTH | ~3 hours of waves | consensus/epoch.rs |
COMMITTEE_SIZE (mainnet) | 128 | consensus/committee.rs |
THRESHOLD (2f+1) | 85 | consensus/quorum.rs |
EQUIVOCATION_THRESHOLD (n-2f) | 44 | consensus/quorum.rs |
RANDOMNESS_THRESHOLD | 85 (sorted before combine) | consensus/epoch_randomness.rs |
RESHARE_AGGREGATION_DELAY_WAVES | 5 | crypto/threshold.rs / validator |
MIN_VALIDATOR_STAKE | 10,000 PYDE | tx/pipeline.rs (single tier) |
MAX_VALIDATORS_PER_OPERATOR | 3 | tx/pipeline.rs (anti-Sybil cap) |
UNBONDING_PERIOD | 30 days | consensus/validator.rs |
FINDER_FEE_PERCENT | 10 | slashing/lib.rs |
EVIDENCE_VERSION | 1 | slashing/lib.rs |
MULTISIG_VERSION | 0x01 | tx/multisig.rs |
MAX_MULTISIG_SIGNERS | 16 | tx/multisig.rs |
MAX_PAUSE_DURATION_WAVES | ~30 days of waves | tx/pipeline.rs |
MAX_BATCH_SIZE | 4 MB | mempool/batch.rs |
C. Gas / Fee Constants
| Constant | Value | Where |
|---|---|---|
GAS_TARGET | 400,000,000 | tx/fee.rs |
GAS_CEILING | 1,600,000,000 (4× target) | tx/fee.rs |
GENESIS_BASE_FEE | 50,000,000,000 quanta | tx/fee.rs |
MIN_BASE_FEE | 1 | tx/fee.rs |
ADJUSTMENT_DIVISOR | 8 (1/8 = 12.5% per block) | tx/fee.rs |
FEE_BURN_PCT | 70 | tx/execution.rs |
FEE_REWARD_POOL_PCT | 20 | tx/execution.rs |
FEE_TREASURY_PCT | 10 | tx/execution.rs |
MIN_GAS_LIMIT | 21,000 | tx/validation.rs |
MAX_TX_SIZE | 128 KB | tx/validation.rs |
MAX_CALLDATA | 64 KB | tx/validation.rs |
WAVES_PER_YEAR | 63,113,904 (2/sec) | tx/fee.rs |
INFLATION_BPS | [500, 300, 200, 100] | tx/fee.rs |
GENESIS_SUPPLY | 10^18 quanta (1B PYDE) | tx/fee.rs |
D. Mempool Constants
| Constant | Value | Where |
|---|---|---|
DEFAULT_MAX_TX_PER_WINDOW_PER_SENDER | 10 | mempool/pool.rs |
DEFAULT_MAX_CONCURRENT_PER_SENDER | 100 | mempool/pool.rs |
RATE_WINDOW_MS | 1000 | mempool/pool.rs |
WINDOW_SIZE (nonce bitmap) | 16 | account/nonce.rs |
MAX_RECEIPT_SLOTS | 10,000 | node/receipt_store.rs |
E. WASM Execution Constants
| Constant | Value | Meaning |
|---|---|---|
| Initial linear memory | 1 MB | Default WASM linear memory per instantiation |
| Max linear memory | 64 MB | Capped by the engine to bound resource use |
| Stack depth limit | Configurable | wasmtime-enforced; rejects modules exceeding cap |
PAGE_ALLOC_GAS | 200 fuel/64KB | Fuel per WASM memory.grow page |
| Default fuel per gas unit | (calibrated) | Established at node startup from the gas table |
MODULE_CACHE_MAX_BYTES | 1 GB (default) | LRU + size-cap + TTL on compiled Module + parsed ABI; per-node tunable. See HOST_FN_ABI_SPEC §3.6 |
MODULE_CACHE_TTL_WAVES | 8 epochs (~1 day) | Cache entries unused longer than this are evicted |
VIEW_FUEL_CAP | 10,000,000 | Per-call wasmtime fuel cap for cross_call_static views (≈ 3 ms commodity). View calls are free; this bounds wall-clock. |
Note: PVM-era constants (4 MB address space, 16+8 register file, 62 opcodes) are retired. WASM's instruction set is the WebAssembly Core Specification; the host-function ABI is defined in companion/HOST_FN_ABI_SPEC.md.
F. Network / Discovery Constants
| Constant | Default | Where |
|---|---|---|
DEFAULT_PORT | 30303 | net/config.rs |
DEFAULT_MAX_PEERS | 50 | net/config.rs |
DEFAULT_MAX_INBOUND | 30 | net/config.rs |
DEFAULT_MAX_OUTBOUND | 20 | net/config.rs |
DEFAULT_RATE_LIMIT_PER_IP | 5 / sec | net/config.rs |
DEFAULT_IDLE_TIMEOUT | 60 s | net/config.rs |
| Gossipsub mesh_n | 8 | net/node.rs |
| Gossipsub heartbeat | 150 ms (DAG round) | net/node.rs |
MAINNET_SEEDS | (set at launch) | net/discovery.rs |
TESTNET_SEEDS | (set at launch) | net/discovery.rs |
MAINNET_DNS_SEED | seed.pyde.network | net/discovery.rs |
G. State Discriminators
Used in Poseidon2(addr || discriminator || sub_key) for storage keys.
Defined in crates/state/src/keys.rs.
| Discriminator | Name | Holds |
|---|---|---|
| 0x12 | SUPPLY | Total PYDE supply counter |
| 0x13 | TOTAL_BURNED | Cumulative fee burn counter |
| 0x14 | REWARDS_PER_STAKE_UNIT | Lazy-accrual per-stake-unit reward accumulator |
| 0x15 | ACTIVE_STAKE_WEIGHTED_TOTAL | Pool divisor (sum of stake × uptime; excludes exited / slashed) |
| 0x16 | VESTING | Per-account vesting schedule (40 bytes) |
| 0x17 | VALIDATOR_SUBSIDY | (total_amount, end_wave) streaming subsidy |
| 0x18 | AIRDROP_ROOT | Genesis airdrop Merkle root |
| 0x19 | AIRDROP_DEADLINE | wave_id after which sweep is allowed |
| 0x1A | AIRDROP_CLAIMED | Per-leaf-index claim bitmap |
| 0x1B | AIRDROP_EXPECTED_SUM | Genesis pool size invariant |
| 0x1C | MULTISIG_SIGNERS | Treasury multisig signer set (FALCON pks) |
| 0x1D | MULTISIG_THRESHOLD | Required signature count |
| 0x1E | MULTISIG_NONCE | Replay-protection counter for multisig |
| 0x1F | EMERGENCY_PAUSE_END_WAVE | End wave_id of an active emergency pause |
H. Transaction Type Registry
Defined in crates/tx/src/types.rs.
Tag 2 is intentionally vacant — Batch was prototyped pre-mainnet and
removed before launch (see Chapter 11 §11.9). A forged tx_type = 2
fails decode.
| ID | Name | Purpose |
|---|---|---|
| 0 | Standard | Value transfer or contract call |
| 1 | Deploy | Contract deployment |
| 3 | StakeDeposit | Lock ≥ 10,000 PYDE and register validator (single tier, uniform-random committee selection per epoch) |
| 4 | StakeWithdraw | Begin 30-day unbonding |
| 5 | Slash | Submit double-sign evidence |
| 6 | ClaimReward | Claim accrued staking yield from the pool |
| 7 | ClaimAirdrop | Claim genesis airdrop with Merkle proof |
| 8 | SweepAirdrop | Move unclaimed airdrop residue to treasury (post-deadline) |
| 9 | MultisigTx | Treasury spend with multisig signatures |
| 10 | RotateMultisig | Rotate multisig signer set + threshold |
| 11 | EmergencyPause | Halt block production (multisig-signed) |
| 12 | EmergencyResume | Resume normal processing |
| 13 | RegisterPubkey | First-time pubkey binding for a funded-but-unregistered account (no sig, no gas; proof is address-derivation) |
I. WASM Host Function Surface (Summary)
Pyde's execution layer is WebAssembly. The WASM instruction set itself is the WebAssembly Core Specification — defined and maintained externally, not by Pyde. What Pyde defines is the Host Function ABI: the chain-side surface that contracts call to interact with state, accounts, crypto, events, and other chain primitives.
The full Host Function ABI specification (signatures, memory layout conventions, gas cost table, versioning rules, parachain-extension allowlist, forbidden imports) lives at companion/HOST_FN_ABI_SPEC.md. The high-level surface, organized by category:
Storage
sload, sstore, sdelete
Balances and transfers
balance, transfer
Execution context
caller, origin, self_address, wave_id, wave_timestamp, chain_id
Events
emit_event
Hashing primitives
keccak256, blake3, poseidon2
Post-quantum cryptography
threshold_encrypt, threshold_decrypt_share, falcon_verify
Cross-contract / cross-parachain
cross_call
Gas accounting
consume_gas
Parachain-extension host functions (parachain-only)
send_xparachain_message, get_committee_info, additional governance hooks (full list in the Host Function ABI spec).
Forbidden imports (enforced at deploy)
Network calls, filesystem access, system clock, non-deterministic entropy, WASM threads, non-deterministic SIMD.
The deploy-time validator rejects any WASM module whose import section references functions outside this allowlist.
J. JSON-RPC Method Index
Full reference in Chapter 17. The methods, prefixed pyde_:
| Method | Returns |
|---|---|
pyde_getBalance | balance (quanta string) |
pyde_getTransactionCount | nonce (u64) |
pyde_getCode | hex bytecode |
pyde_getStorageAt | hex value |
pyde_chainId | hex chain_id |
pyde_blockNumber | hex head wave_id |
pyde_gasPrice | base fee (quanta) |
pyde_stateRoot | current state root |
pyde_syncing | sync status object |
pyde_getValidators | validators with status + stake |
pyde_getBlockByNumber | BlockHeader |
pyde_getBlockByHash | BlockHeader |
pyde_getTransactionReceipt | receipt with logs + fee breakdown |
pyde_getLogs | matching logs |
pyde_mempoolSize | pending tx count |
pyde_sendRawTransaction | tx hash |
pyde_sendTransaction | (dev only) tx hash |
pyde_sendEncryptedTransaction | tx hash |
pyde_call | view-function return data (FREE off-chain) |
pyde_estimateGas | gas estimate |
pyde_createAccessList | inferred access list |
pyde_getHardFinalityCert | committee-signed cert for a wave (incl. state_root + events_root + events_bloom) |
pyde_getSnapshotManifest | snapshot manifest for state sync |
pyde_resolveName | name → address registry lookup |
WebSocket subscriptions (via pyde_subscribe({method, ...})): newHeads
(wave commits), accountChanges, logs (events with AND+OR topic / contract
filter; at-least-once delivery with cursor for dedup). pyde_resubscribe({from: cursor})
resumes a logs stream after disconnect. Full mechanics:
HOST_FN_ABI_SPEC §15.5.
K. Cryptographic Primitives Summary
| Purpose | Primitive | Sizes |
|---|---|---|
| Digital signatures | FALCON-512 (NIST FIPS 206) | pk 897 B, sk 1281 B, sig ~666 B |
| Key encapsulation | Kyber-768 / ML-KEM (FIPS 203) | pk 1184 B, sk seed 64 B, ct 1088 B |
| High-volume hashing | Blake3 | 256-bit output, ~3 GB/s native |
| ZK-bearing hashing | Poseidon2 over Goldilocks | 256-bit output, ~400 constraints/hash |
| Threshold encryption | Shamir SSS + Kyber + Poseidon2 | 85-of-128, ~250 B per share |
| PSS resharing | Lagrange interpolation over Goldilocks | preserves underlying secret |
| DKG | Pedersen DKG over Kyber-768 | per-epoch threshold pubkey |
| VRF | FALCON-proof + Poseidon2 output | inherits FALCON security |
| Symmetric AEAD | AES-256-GCM (hardware-accelerated) | 32-byte key, 16-byte tag |
| Address | Poseidon2(falcon_pubkey) | 32 bytes |
No elliptic curves anywhere in the protocol.
L. Post-Mainnet Plan
Items explicitly out of scope for the launch network, with the rough priority each is tracked at:
| Item | Priority | Notes |
|---|---|---|
| Persistent receipt store (archive-node mode) | High | Task 058. Needed for production explorers. |
| ML-KEM upgrade from 0.3.0-rc to stable | High | Task 057. Once NIST stable releases. |
| Algebraic batch FALCON verification | High | Per-block verification cost reduction. |
| Signed-mempool commitments + censorship slashing | High | Replaces local-view mandatory inclusion. |
| Pedersen / KZG commitments for PSS resharing | High | Closes the malicious-contributor edge case. |
| Graceful drain-and-shutdown on persist failure | Medium | Task 014e. Operational polish. |
| Two-dimensional gas (exec + prove) | Medium | Depends on ZK proving landing. |
| Off-chain Merkle builder CLI for airdrop ops | Medium | Operator tooling, ~150 LOC. |
| Mempool-level filter during emergency pause | Low | Cleaner than gate-check at admission. |
| Sentry-node validator hiding | Low | Operational pattern, not protocol. |
| Sophisticated peer scoring | Medium | Multi-topic + decay parameters. |
| Fancy version-signaling on-chain | Low | Currently out-of-band. |
| ZK validity proofs (STARK proving) | Research | Major redesign; restores prover economics. |
| Native Ethereum bridge | High | FALCON-in-EVM verifier + Patricia verifier as a Pyde WASM contract. |
| Native Bitcoin bridge | Medium | SPV-style proofs; PoW finality is probabilistic. |
| Parachain SDK (Rust / Go / C++) | Medium | Sovereign chains sharing Pyde security. |
| TypeScript SDK | Medium | WASM bridge available now; dedicated TS later. |
| Native browser wallet | Low | Ecosystem; WASM exposes primitives. |
| Block-explorer frontend | High | Backend in Phase 7; UI is ecosystem. |
The list is the project's tracked future work, not a commitment timeline. Each item moves on PIP merit, audit capacity, and ecosystem demand.
M. Key References in the Codebase
For readers diving into the source. The pre-pivot crates listed below
(crypto, state, account, slashing, tx, consensus, networking, mempool,
node) live in the pyde-net/archive
repository, preserved with full git history. The post-pivot WASM
execution layer crate (wasm-exec) is to be implemented in a
freshly-cut workspace when the WASM-era engine repo is bootstrapped —
the row below is forward-looking. Paths are relative to whichever
workspace the file ends up in (archive workspace for pre-pivot rows,
the future post-pivot workspace for wasm-exec).
| Subsystem | Key files |
|---|---|
| Crypto stack | crates/crypto/src/{falcon,kyber,poseidon2,threshold,vrf}.rs |
| State commitment | crates/state/src/jmt_store.rs, witness.rs, keys.rs |
| Account record | crates/account/src/{types,address,nonce}.rs |
| Slashing constants | crates/slashing/src/lib.rs |
| TX types + pipeline | crates/tx/src/{types,validation,pipeline,fee,execution}.rs |
| Multisig / governance | crates/tx/src/multisig.rs, crates/tx/src/vesting.rs |
| Airdrop | crates/tx/src/airdrop.rs |
| Consensus | crates/consensus/src/{dag,vertex,wave,anchor,subdag,validator,finality,slashing,epoch_randomness,committee,quorum,round}.rs |
| Networking | crates/net/src/{node,channels,auth,peer,ddos,discovery,config}.rs |
| Mempool | crates/mempool/src/{pool,block_builder,inclusion,encrypted}.rs |
| Node binary + RPC | crates/node/src/{main,cli,rpc,validator,consensus_store,receipt_store}.rs |
| WASM execution layer (to be implemented) | wasm-exec/src/{lib,host_fns,module_cache,gas_meter,validate}.rs (post-pivot) |
otigen developer toolchain | pyde-net/otigen (separate repo): subcommand framework, otigen.toml schema, language detection, state binding generators (Rust/AS/Go/C), deploy flow, wallet |
| Rust SDK | crates/pyde-rust-sdk/src/{lib,client,wallet,contract,signer,abi,types,ws}.rs |
| WASM crypto | crates/pyde-crypto-wasm/src/lib.rs |
Launch plan, hardening status, and the phased route to mainnet: chapter 19 (Launch Strategy).
N. Where the Numbers Came From
The key headline figures, with their sources:
| Claim | Source |
|---|---|
| ~150 ms DAG round period | ROUND_PERIOD_MS in consensus/round.rs |
| ~500 ms median commit | COMMIT_TARGET_MS in consensus/commit.rs |
| v1 plaintext throughput target | Awaiting multi-region performance harness measurement; publish only what the harness measures under sustained, production-realistic conditions (companion/PERFORMANCE_HARNESS.md) |
| v1 encrypted throughput target | Same harness; reduced by threshold-decryption serial cost |
| 70 / 20 / 10 fee split | FEE_BURN_PCT etc in tx/execution.rs |
| 5% → 1% inflation schedule | INFLATION_BPS in tx/fee.rs |
| 10,000 PYDE validator min stake | MIN_VALIDATOR_STAKE in tx/pipeline.rs (single tier) |
| 3 max validators per operator | MAX_VALIDATORS_PER_OPERATOR in tx/pipeline.rs (anti-Sybil) |
| 30-day unbonding | UNBONDING_PERIOD in consensus/validator.rs |
| 16-slot nonce window | WINDOW_SIZE in account/nonce.rs |
| 128 KB tx / 64 KB calldata caps | MAX_TX_SIZE, MAX_CALLDATA in tx/validation.rs |
| 4 MB batch hard cap | MAX_BATCH_SIZE in mempool/batch.rs |
| 1 MB witness cap | MAX_WITNESS_SIZE in state/witness.rs |
| WASM host function ABI v1.0 | wasm-exec/src/host_fns.rs (post-pivot) + companion/HOST_FN_ABI_SPEC.md |
| wasmtime + Cranelift AOT | Pinned wasmtime version in Cargo.toml |
| Module cache size | MODULE_CACHE_SIZE in wasm-exec/src/module_cache.rs (post-pivot) |
| Committee 128, threshold 85 | COMMITTEE_SIZE, THRESHOLD in consensus/quorum.rs |
| 85-of-128 threshold for decryption | RANDOMNESS_THRESHOLD (and equivalent for Kyber) |
O. License and Contribution
The Pyde codebase is licensed under Apache 2.0 (workspace-wide, in
Cargo.toml). Contributions go through the PR process at
github.com/pyde-net/.... Substantive protocol changes go through a PIP
first (see Chapter 15).
This book is part of the project repository. Corrections and additions are welcomed via PR.
End Notes
Pyde is a sovereign post-quantum L1. Mainnet ships:
- No elliptic curves — FALCON-512, Kyber-768, Blake3, Poseidon2, lattice VRF.
- Mysticeti-style consensus, no proposers — each round every committee member produces a vertex; canonical order is structural.
- Uniform Block-STM execution — optimistic parallel exec + MVCC validation; access lists from
pyde_simulateTransactiondrive PIP-3 multiget prefetch into the dashmap cache, never partition the wave. - Optional threshold encryption — opt in per-tx for MEV protection; plaintext supported at lower cost.
- No tip mechanism — fees are exactly
gas_used × base_fee. - No on-chain stake-weighted vote — governance is PIPs + on-chain multisig.
- No bridge at v1 —
cross_call!macro stable; parachain operator layer ships post-mainnet. - Structural MEV protection — commit-before-reveal + structural ordering + no tips = unexpressible MEV.
Everything that doesn't ship at mainnet is tracked, scoped, and prioritized for post-launch work. Honesty about what's in vs out is the single biggest difference between this book and earlier drafts.
The next thing to read isn't a separate file — it's chapter 19 (Launch Strategy), where the phased work-in-flight to mainnet lives. The Companion Specifications section of this book holds the full technical specs (Whitepaper, Design, Threat Model, Performance Harness, Parachain Design, Brand, and more).
Migration Notes (2026 Pivot)
This page is the migration log between the pre-pivot Pyde architecture (in-house HotStuff consensus) and the post-pivot architecture (Mysticeti DAG consensus + hybrid hashing + optional encryption). The book itself has been rewritten in place; this page exists as a single reference for what changed and why, useful for readers who came in mid-flight or who need to reconcile against pre-pivot artifacts.
The Pivot, In One Page
Before (HotStuff variant):
- Single-proposer-per-slot BFT consensus with 400 ms slot timing.
- View-change protocol for proposer failures.
- Encrypted mempool with proposer-asserted ordering commitment.
- Validators each stake a fixed 10K PYDE; equal vote weight.
- Sparse Merkle Tree (256-deep) for state.
- Poseidon2 hashing everywhere.
- Targeted 12.5K TPS sustained / 50K peak as headline.
After (Mysticeti DAG):
- DAG consensus — every round every committee member produces one vertex.
- No proposers, no view changes. Anchor selection is deterministic.
- Optional threshold encryption per-tx (plaintext or encrypted).
- Single-tier staking — 10,000 PYDE minimum, uniform-random committee selection per epoch, operator-identity cap (3 per operator).
- Jellyfish Merkle Tree (radix-16, path-compressed).
- Hybrid hashing — Blake3 (high-volume native) + Poseidon2 (ZK-bearing).
- v1 honest throughput target (to be established by the multi-region performance harness) on commodity committee hardware, per the publishing discipline: publish only what the harness measures under sustained, production-realistic conditions — never lab extrapolations or microbenchmark peaks.
Why the Pivot
The HotStuff variant accumulated wedges, head-divergence deadlocks, and view-change cascades that resisted patching. Lab measurements peaked at ~4K TPS (full launch tests never ran). Repeated incidents under simple multi-node tests suggested the issue was structural, not implementation. The team chose a clean break: remove the consensus, mempool, and networking layers from the workspace; rebuild with the Mysticeti DAG protocol that Sui has been running in production since 2024.
Component-by-Component Diff
| Component | Pre-pivot | Post-pivot |
|---|---|---|
| Consensus | HotStuff variant, 1 proposer/slot | Mysticeti DAG, 128 vertices/round |
| Slot timing | 400 ms slot | ~150 ms round, ~500 ms median commit |
| Ordering | Proposer-asserted ordering commitment | Structural via committed subdag |
| Validator architecture | Monolithic | Worker (tx batching) + Primary (consensus) |
| Mempool | Always-encrypted | Optional encryption per-tx |
| State tree | Fixed-depth Sparse Merkle Tree | Jellyfish Merkle Tree (radix-16, path-compressed) |
| Hashing | Poseidon2 everywhere | Blake3 (native) + Poseidon2 (ZK-bearing) |
| State root | Single Poseidon2 root | Dual: Blake3 + Poseidon2 |
| Execution | Otigen era: static access lists only. Intermediate proposal (dropped): hybrid static + Block-STM speculation. | Current v1: uniform Block-STM; access list is an optional prefetch hint (PIP-3 multiget cache warm-up) and never partitions execution. |
| Staking model | Single 10K PYDE | Single 10K PYDE (unchanged; an interim mid-pivot draft of the book proposed 10M/100K tiers — that was an error; flat-tier with operator-cap was the actual decision) |
| Reward distribution | Direct proposer share (20%) | Epoch reward pool (20%, distributed by stake×uptime) |
| Peer discovery | Kademlia DHT | Layered (seeds → DNS → on-chain registry → PEX → cache) |
| Committee defense | Operational sentry pattern only | Sentry pattern with protocol support |
| Cross-chain | Stub cross_call! | cross_call! + parachain operator network (v2) |
| Account abstraction | Single + Multisig | Single + Multisig (max 16) + Programmable (v2 reserved) |
What Stayed the Same
- FALCON-512 signatures everywhere. Untouched.
- Kyber-768 threshold encryption primitive. Untouched (now opt-in per-tx instead of mandatory).
- 70/20/10 fee split. Recipient of 20% changed (proposer → reward pool) but the percentages held.
- 16-slot nonce window per account. Untouched.
- Gas tank + paymaster sponsored-tx model. Untouched.
- Treasury multisig + emergency pause governance model. Untouched (multisig threshold raised to 7-of-12 typical).
Reading Order if You Knew the Pre-Pivot Book
If you're returning to the book after the pivot, the chapters that changed most are:
- Chapter 6 (Consensus) — full rewrite; HotStuff → Mysticeti DAG.
- Chapter 7 (State Sync & Chain Halt) — new chapter, operational procedures absent in pre-pivot.
- Chapter 9 (MEV Protection) — restructured for DAG ordering.
- Chapter 4 (State Model) — hybrid hashing, dual state roots.
- Chapter 8 (Cryptography) — Blake3 added; Poseidon2 scope narrowed.
- Chapter 12 (Networking) — DHT removed; layered discovery + sentry.
- Chapter 14 (Tokenomics) — single-tier staking (10K PYDE min, uniform-random committee selection, operator-identity cap), reward pool, updated inflation math.
- Chapter 19 (Launch Strategy) — timeline reset post-pivot.
- Chapter 20 (Appendix) — glossary, constants, post-mainnet plan updated.
Chapters that changed less:
- Chapter 3 (Execution Layer) — full rewrite for WebAssembly via wasmtime (post-pivot).
- Chapter 5 (Otigen Toolchain) — full rewrite as the developer toolchain (the binary; name carried forward from the retired language).
- Chapter 10 (Gas/Fee) — commit-cadence + honest TPS numbers.
- Chapter 11 (Account Model) — reserved
ProgrammableAuthKeys variant for v2. - Chapter 13 (Cross-Chain) — parachain layer framed as permissionless operator network (v2), not auctioned slots.
- Chapter 16 (Security) — DAG safety argument replaces HotStuff one; attack surface table updated.
- Chapters 15, 17, 18 — minor parameter / API updates.
Honest Status
The post-pivot architecture is now substantially implemented. Devnet runs a multi-validator Mysticeti committee with WASM execution, threshold encryption, per-epoch DKG resharing, and state-sync. Credible public performance numbers are still gated on the multi-region harness.
| Component | Status |
|---|---|
| Architecture design | ✅ Complete |
| WASM execution (wasmtime + Cranelift AOT, Block-STM) | 🟢 Live; pooled Engine, Host Function ABI v1.0 frozen, Block-STM wired into the commit walk |
| State (JMT + hybrid Blake3 / Poseidon2 dual root) | 🟢 Wired; StateRoot { blake3, poseidon2 } end-to-end |
| Mysticeti DAG consensus | 🟡 Vertex / anchor / beacon / committee / wave commit live; multi-validator genesis DKG + state-sync replay shipped; soak-test hardening and resharing edge cases in flight |
| Threshold cryptography (Kyber-768 + PSS-refresh) | 🟡 DKG + per-epoch resharing + live hot-swap shipped; encrypted-tx survival across rotation still tracked as an open bug |
| Network protocol (libp2p + QUIC + Gossipsub) | 🟢 Migrated; layered discovery, peer scoring, sentry-friendly topology |
| Performance harness | 🟡 Local soak-test driver + multi-validator cluster CLI live; multi-region rig + chaos scenarios not yet built |
| SDKs (TypeScript + Rust) | 🟡 pyde-ts-sdk 0.1.0 staged; Rust SDK in progress |
The multi-region performance harness is still the bottleneck on credible TPS claims. No external number leaves this project without harness evidence: publish only what the harness measures under sustained, production-realistic conditions — never lab extrapolations or microbenchmark peaks.
Related Docs
- Full technical design: companion/DESIGN.md
- Whitepaper: companion/WHITEPAPER.md
- Threat model: companion/THREAT_MODEL.md
- Failure scenarios: companion/FAILURE_SCENARIOS.md
- Performance harness spec: companion/PERFORMANCE_HARNESS.md
- Mainnet plan: Launch Strategy