From e2a7295310e7b58e56de584e35c72b3cd12e4078 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Mon, 2 Mar 2026 07:50:26 +0000 Subject: [PATCH] rpc: add catalyst_getTxDomain for tx signing domain This provides a single-call domain fetch (chain_id/genesis_hash/network_id) to avoid load-balancer backend skew when tooling signs CTX2 transactions. cli: deploy from artifacts and add wait/verify - Accept Foundry/Hardhat artifact JSON as deploy input - Add --wait/--verify-code/timeout/poll flags - Implement generate-identity Made-with: Cursor --- crates/catalyst-cli/src/commands.rs | 180 +++++++++++++++--- crates/catalyst-cli/src/main.rs | 35 +++- crates/catalyst-rpc/src/lib.rs | 36 ++++ docs/agent-prompt-catalyst-contracts.md | 76 ++++++++ docs/agent-prompt-catalyst-sdk.md | 92 ++++++++++ docs/evm-deploy.md | 62 +++++++ docs/tokenomics-spec.md | 231 ++++++++++++++++++++++++ 7 files changed, 681 insertions(+), 31 deletions(-) create mode 100644 docs/agent-prompt-catalyst-contracts.md create mode 100644 docs/agent-prompt-catalyst-sdk.md create mode 100644 docs/evm-deploy.md create mode 100644 docs/tokenomics-spec.md diff --git a/crates/catalyst-cli/src/commands.rs b/crates/catalyst-cli/src/commands.rs index 4057f65..7ad67af 100644 --- a/crates/catalyst-cli/src/commands.rs +++ b/crates/catalyst-cli/src/commands.rs @@ -6,6 +6,7 @@ use anyhow::Result; use std::path::Path; +use jsonrpsee::http_client::HttpClient; use jsonrpsee::http_client::HttpClientBuilder; use jsonrpsee::core::client::ClientT; @@ -53,16 +54,26 @@ fn parse_u64_hex(s: &str) -> Option { u64::from_str_radix(s, 16).ok() } -async fn fetch_chain_domain(rpc_url: &str) -> Option<(u64, [u8; 32])> { - let client = HttpClientBuilder::default().build(rpc_url).ok()?; - let chain_id_hex: String = client - .request("catalyst_chainId", jsonrpsee::rpc_params![]) - .await - .ok()?; - let genesis_hex: String = client - .request("catalyst_genesisHash", jsonrpsee::rpc_params![]) +async fn fetch_chain_domain(client: &HttpClient) -> Option<(u64, [u8; 32])> { + #[derive(serde::Deserialize)] + struct RpcTxDomainLite { + chain_id: String, + genesis_hash: String, + } + + // Preferred: single call to avoid backend skew behind load balancers. + if let Ok(dom) = client + .request::("catalyst_getTxDomain", jsonrpsee::rpc_params![]) .await - .ok()?; + { + let chain_id = parse_u64_hex(&dom.chain_id)?; + let genesis_hash = parse_hex_32(&dom.genesis_hash).ok().unwrap_or([0u8; 32]); + return Some((chain_id, genesis_hash)); + } + + // Fallback for older nodes. + let chain_id_hex: String = client.request("catalyst_chainId", jsonrpsee::rpc_params![]).await.ok()?; + let genesis_hex: String = client.request("catalyst_genesisHash", jsonrpsee::rpc_params![]).await.ok()?; let chain_id = parse_u64_hex(&chain_id_hex)?; let genesis_hash = parse_hex_32(&genesis_hex).ok().unwrap_or([0u8; 32]); Some((chain_id, genesis_hash)) @@ -109,8 +120,20 @@ fn verify_merkle_proof(root: &[u8; 32], leaf: &[u8; 32], steps: &[String]) -> an } pub async fn generate_identity(output: &Path) -> Result<()> { - let _ = output; - // TODO: implement persistent identity generation. + anyhow::ensure!( + !output.exists(), + "refusing to overwrite existing key file: {}", + output.display() + ); + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent)?; + } + let mut rng = rand::rngs::OsRng; + let sk = catalyst_crypto::PrivateKey::generate(&mut rng); + crate::identity::save_private_key_hex(output, &sk)?; + let pk = crate::identity::public_key_bytes(&sk); + println!("wrote: {}", output.display()); + println!("pubkey: 0x{}", hex::encode(pk)); Ok(()) } @@ -885,7 +908,7 @@ pub async fn send_transaction( tx.core.fees = catalyst_core::protocol::min_fee(&tx); // Real signature: prefer v2 domain-separated payload; fall back to v1 + legacy. - let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(rpc_url).await { + let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(&client).await { tx.signing_payload_v2(chain_id, genesis_hash) .or_else(|_| tx.signing_payload_v1(chain_id, genesis_hash)) .map_err(anyhow::Error::msg)? @@ -959,7 +982,7 @@ pub async fn register_worker(key_file: &Path, rpc_url: &str) -> Result<()> { tx.core.fees = catalyst_core::protocol::min_fee(&tx); // Real signature: prefer v2 domain-separated payload; fall back to v1 + legacy. - let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(rpc_url).await { + let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(&client).await { tx.signing_payload_v2(chain_id, genesis_hash) .or_else(|_| tx.signing_payload_v1(chain_id, genesis_hash)) .map_err(anyhow::Error::msg)? @@ -998,10 +1021,84 @@ pub async fn deploy_contract( key_file: &Path, rpc_url: &str, runtime: &str, + wait: bool, + verify_code: bool, + timeout_secs: u64, + poll_ms: u64, ) -> Result<()> { - let _ = (args, runtime); + let _ = runtime; let client = HttpClientBuilder::default().build(rpc_url)?; + fn decode_hex_bytes_any(s: &str) -> anyhow::Result> { + let s = s.trim(); + let s = s.strip_prefix("0x").unwrap_or(s); + if s.is_empty() { + return Ok(Vec::new()); + } + anyhow::ensure!( + s.chars().all(|c| c.is_ascii_hexdigit()) && s.len() % 2 == 0, + "expected hex string" + ); + Ok(hex::decode(s)?) + } + + fn extract_artifact_bytecode(v: &serde_json::Value) -> Option { + // Foundry/Hardhat commonly use: + // - { "bytecode": { "object": "0x..." } } + // - { "bytecode": "0x..." } + // Try those first. + if let Some(b) = v.get("bytecode") { + if let Some(s) = b.as_str() { + return Some(s.to_string()); + } + if let Some(obj) = b.get("object").and_then(|o| o.as_str()) { + return Some(obj.to_string()); + } + } + // Some toolchains nest further under "data". + if let Some(data) = v.get("data") { + if let Some(b) = data.get("bytecode") { + if let Some(s) = b.as_str() { + return Some(s.to_string()); + } + if let Some(obj) = b.get("object").and_then(|o| o.as_str()) { + return Some(obj.to_string()); + } + } + } + None + } + + fn read_contract_initcode(path: &Path, ctor_args_hex: Option<&str>) -> anyhow::Result> { + let raw = std::fs::read(path)?; + let mut bytecode: Vec = if let Ok(s) = std::str::from_utf8(&raw) { + let s = s.trim(); + if s.starts_with('{') { + let v: serde_json::Value = serde_json::from_str(s)?; + let hexb = extract_artifact_bytecode(&v) + .ok_or_else(|| anyhow::anyhow!("artifact JSON missing `bytecode`"))?; + decode_hex_bytes_any(&hexb)? + } else { + // hex text or raw bytes stored in a file. + let maybe = s.strip_prefix("0x").unwrap_or(s); + if maybe.chars().all(|c| c.is_ascii_hexdigit()) && maybe.len() % 2 == 0 { + hex::decode(maybe)? + } else { + raw + } + } + } else { + raw + }; + + if let Some(args_hex) = ctor_args_hex { + let args_bytes = decode_hex_bytes_any(args_hex)?; + bytecode.extend_from_slice(&args_bytes); + } + anyhow::ensure!(!bytecode.is_empty(), "contract bytecode is empty"); + Ok(bytecode) + } + // Load sender key let sk = crate::identity::load_or_generate_private_key(key_file, true)?; let from_pk = crate::identity::public_key_bytes(&sk); @@ -1014,19 +1111,11 @@ pub async fn deploy_contract( .unwrap_or(0); let nonce = cur_nonce.saturating_add(1); - // Read bytecode: accept a file containing hex (0x...) or raw bytes. - let raw = std::fs::read(contract)?; - let bytecode = if let Ok(s) = std::str::from_utf8(&raw) { - let s = s.trim(); - let s = s.strip_prefix("0x").unwrap_or(s); - if s.chars().all(|c| c.is_ascii_hexdigit()) && s.len() % 2 == 0 { - hex::decode(s)? - } else { - raw - } - } else { - raw - }; + // Read initcode from: + // - hex/raw file OR + // - Foundry/Hardhat artifact JSON. + // If `--args` is provided, append it (hex) to bytecode. + let bytecode = read_contract_initcode(contract, args)?; let now_ms = catalyst_utils::utils::current_timestamp_ms(); let lock_time_secs: u32 = 0; @@ -1052,7 +1141,7 @@ pub async fn deploy_contract( }; tx.core.fees = catalyst_core::protocol::min_fee(&tx); - let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(rpc_url).await { + let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(&client).await { tx.signing_payload_v2(chain_id, genesis_hash) .or_else(|_| tx.signing_payload_v1(chain_id, genesis_hash)) .map_err(anyhow::Error::msg)? @@ -1085,6 +1174,39 @@ pub async fn deploy_contract( println!("tx_id: {tx_id}"); println!("contract_address: 0x{}", hex::encode(created.as_slice())); + + if wait { + use tokio::time::{sleep, Duration, Instant}; + let start = Instant::now(); + let tx_hash = tx_id.clone(); + loop { + let receipt: Option = client + .request("catalyst_getTransactionReceipt", jsonrpsee::rpc_params![tx_hash.clone()]) + .await?; + if let Some(r) = receipt { + if r.status == "applied" || r.status == "failed" { + println!("receipt_status: {}", r.status); + if verify_code { + let addr = format!("0x{}", hex::encode(created.as_slice())); + let code: String = client + .request("catalyst_getCode", jsonrpsee::rpc_params![addr]) + .await?; + if code.trim() == "0x" { + anyhow::bail!("deployment applied but `catalyst_getCode` returned empty code"); + } + println!("code_verified: true"); + } + break; + } + } + + if start.elapsed() > Duration::from_secs(timeout_secs.max(1)) { + anyhow::bail!("timed out waiting for tx to be applied (tx_id={tx_hash})"); + } + sleep(Duration::from_millis(poll_ms.max(100))).await; + } + } + Ok(()) } @@ -1145,7 +1267,7 @@ pub async fn call_contract( }; tx.core.fees = catalyst_core::protocol::min_fee(&tx); - let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(rpc_url).await { + let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(&client).await { tx.signing_payload_v2(chain_id, genesis_hash) .or_else(|_| tx.signing_payload_v1(chain_id, genesis_hash)) .map_err(anyhow::Error::msg)? diff --git a/crates/catalyst-cli/src/main.rs b/crates/catalyst-cli/src/main.rs index 11c408f..9a93303 100644 --- a/crates/catalyst-cli/src/main.rs +++ b/crates/catalyst-cli/src/main.rs @@ -272,7 +272,7 @@ enum Commands { }, /// Deploy a smart contract Deploy { - /// Contract bytecode file + /// Contract bytecode file (hex/raw) or a Foundry/Hardhat artifact JSON contract: PathBuf, /// Constructor arguments (hex) @@ -290,6 +290,22 @@ enum Commands { /// Runtime type (evm, svm, wasm) #[arg(long, default_value = "evm")] runtime: String, + + /// Wait for the transaction to be applied (poll receipt) + #[arg(long)] + wait: bool, + + /// When waiting, fail if deployed code is empty (`catalyst_getCode` == `0x`) + #[arg(long)] + verify_code: bool, + + /// When waiting, maximum time to wait for apply + #[arg(long, default_value = "180")] + timeout_secs: u64, + + /// When waiting, receipt poll interval (milliseconds) + #[arg(long, default_value = "1500")] + poll_ms: u64, }, /// Call a smart contract function Call { @@ -499,8 +515,23 @@ async fn main() -> Result<()> { key_file, rpc_url, runtime, + wait, + verify_code, + timeout_secs, + poll_ms, } => { - commands::deploy_contract(&contract, args.as_deref(), &key_file, &rpc_url, &runtime).await?; + commands::deploy_contract( + &contract, + args.as_deref(), + &key_file, + &rpc_url, + &runtime, + wait, + verify_code, + timeout_secs, + poll_ms, + ) + .await?; } Commands::Call { contract, diff --git a/crates/catalyst-rpc/src/lib.rs b/crates/catalyst-rpc/src/lib.rs index f9a9b45..b337d5d 100644 --- a/crates/catalyst-rpc/src/lib.rs +++ b/crates/catalyst-rpc/src/lib.rs @@ -115,6 +115,13 @@ pub trait CatalystRpc { #[method(name = "catalyst_genesisHash")] async fn genesis_hash(&self) -> RpcResult; + /// Get the chain domain used for transaction signing/verifying. + /// + /// This is the preferred method for tooling/SDKs because it returns all domain + /// parameters in a single RPC call (avoids load-balancer backend skew). + #[method(name = "catalyst_getTxDomain")] + async fn get_tx_domain(&self) -> RpcResult; + /// Get sync/snapshot metadata needed for fast-sync verification. #[method(name = "catalyst_getSyncInfo")] async fn get_sync_info(&self) -> RpcResult; @@ -253,6 +260,21 @@ pub struct RpcSyncInfo { pub head: RpcHead, } +/// Transaction signing domain for CTX2 transactions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcTxDomain { + /// Hex string `0x...` (u64) used in domain separation. + pub chain_id: String, + /// Network id string (human-readable). + pub network_id: String, + /// Hex string `0x...` (32 bytes) used in domain separation. + pub genesis_hash: String, + /// Envelope wire version (`catalyst-utils` `MessageEnvelope` wire). + pub protocol_version: u32, + /// Transaction wire version (currently 2 for `CTX2`). + pub tx_wire_version: u32, +} + /// Operator-published snapshot metadata for fast sync. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RpcSnapshotInfo { @@ -614,6 +636,20 @@ impl CatalystRpcServer for CatalystRpcImpl { } } + async fn get_tx_domain(&self) -> RpcResult { + let chain_id = self.chain_id().await?; + let network_id = self.network_id().await?; + let genesis_hash = self.genesis_hash().await?; + + Ok(RpcTxDomain { + chain_id, + network_id, + genesis_hash, + protocol_version: catalyst_utils::network::PROTOCOL_VERSION, + tx_wire_version: 2, + }) + } + async fn get_sync_info(&self) -> RpcResult { Ok(RpcSyncInfo { chain_id: self.chain_id().await?, diff --git a/docs/agent-prompt-catalyst-contracts.md b/docs/agent-prompt-catalyst-contracts.md new file mode 100644 index 0000000..78f1ca9 --- /dev/null +++ b/docs/agent-prompt-catalyst-contracts.md @@ -0,0 +1,76 @@ +## Prompt: scaffold `catalyst-contracts` (contracts repo) for Catalyst EVM deployments + +You are building a new repository named **`catalyst-contracts`** intended to hold Catalyst’s public Solidity contracts (starting with CNS/name service), plus a ready-to-run workflow for compiling and deploying them to Catalyst. + +This repo should be structured so it works well with: +- Foundry (primary) +- Hardhat (secondary) +- Catalyst-native deployment tooling (`catalyst-deploy` / `catalyst-cli deploy`) + +### Goals (MVP) + +- A clean contract workspace with: + - `src/` Solidity sources + - `test/` unit tests + - `script/` deploy scripts (Foundry) + - `artifacts/` ignored by git (optional) +- A minimal “hello contract” that can be deployed and called. +- A first “real” folder reserved for CNS contracts: + - `src/cns/` (placeholders are fine) + - Keep contracts modular (namehash, registry, resolver) so they can evolve. +- A documented deployment process that targets Catalyst RPC and Catalyst transaction format. + +### Non-goals (for MVP) + +- Do not implement ENS/CNS business logic unless explicitly provided. +- Do not require an Ethereum node; Catalyst has its own RPC. + +### Repo layout (recommended) + +- `foundry.toml` +- `package.json` (optional, only if Hardhat or tooling is included) +- `src/` + - `examples/Counter.sol` + - `cns/` (directory reserved for CNS suite) +- `test/` + - `Counter.t.sol` +- `script/` + - `DeployCounter.s.sol` (produces initcode + prints constructor args encoding) +- `docs/` + - `deploy.md` + +### Deployment workflow (must be easy) + +Because Foundry cannot broadcast to Catalyst via Ethereum JSON-RPC, the workflow should be: + +1. Compile with Foundry to produce an artifact JSON: + - `forge build` +2. Deploy using Catalyst tooling: + - `catalyst-deploy --rpc-url ... --key-file ... --artifact out/.../Counter.json --wait --verify-code` + - OR `catalyst-cli deploy ...` if `catalyst-deploy` is not available. + +Include this as copy/paste docs. + +### Required documentation (`docs/deploy.md`) + +- Prereqs: `forge`, `node` (if needed), and Catalyst deploy tool +- How to create a `wallet.key` (32-byte hex private key file) +- How to compile (`forge build`) +- How to deploy from Foundry artifact JSON +- How to verify deployment (`catalyst_getCode`) +- How deterministic contract addresses are computed on Catalyst (brief) + +### Testing + +- Unit tests should run with `forge test`. +- Add one optional “live deploy smoke test” script that is only run when env vars are set: + - `CATALYST_RPC_URL` + - `CATALYST_KEY_FILE` + +### Deliverables checklist + +- Contract workspace compiles with `forge build` +- Unit tests pass with `forge test` +- `docs/deploy.md` gives an end-to-end deploy walkthrough for Catalyst +- CNS directory exists and is ready to receive the real contract set + diff --git a/docs/agent-prompt-catalyst-sdk.md b/docs/agent-prompt-catalyst-sdk.md new file mode 100644 index 0000000..7e392b3 --- /dev/null +++ b/docs/agent-prompt-catalyst-sdk.md @@ -0,0 +1,92 @@ +## Prompt: build `catalyst-sdk` (TypeScript) + deploy tooling + +You are building a new public repository named **`catalyst-sdk`** that makes it easy for developers to deploy and interact with EVM smart contracts on **Catalyst**. + +Catalyst is **not Ethereum**: it does not accept Ethereum transactions via `eth_sendRawTransaction`, and it uses **Catalyst CTX2 transactions** submitted via `catalyst_sendRawTransaction`. The SDK must hide these differences behind a familiar DX. + +### Goals (MVP) + +- Provide a **TypeScript SDK** that can: + - Fetch the **transaction signing domain** from RPC using **one call**: `catalyst_getTxDomain`. + - Build a **CTX2 SmartContract** transaction for EVM `CREATE` (deploy) and `CALL`. + - Sign the transaction using a **32-byte private key** (hex) compatible with `wallet.key`. + - Submit via `catalyst_sendRawTransaction`. + - Poll `catalyst_getTransactionReceipt` until `applied`/`failed` with timeout. + - Verify deploy by calling `catalyst_getCode` and ensuring it is non-empty. +- Provide a **CLI** (npm bin) `catalyst-deploy` that: + - Accepts Foundry and Hardhat **artifact JSON** paths and/or raw bytecode files. + - Deploys with `--rpc-url`, `--key-file`, `--args`, `--wait`, `--verify-code`. + - Prints `tx_id` and deterministic `contract_address`. +- Provide a **Hardhat plugin** (`hardhat-catalyst`) that uses the SDK to deploy artifacts and run basic calls. + +### Non-goals (for MVP) + +- Do not try to make Foundry “broadcast” work by emulating Ethereum JSON-RPC. +- Do not implement Solidity compilation from scratch; rely on Foundry/Hardhat outputs. +- Do not design new on-chain standards in the SDK; just tooling. + +### Compatibility constraints (must follow) + +- RPC methods used: + - `catalyst_getTxDomain` → returns `{ chain_id, network_id, genesis_hash, protocol_version, tx_wire_version }` + - `catalyst_getNonce(0x)` → returns `u64` + - `catalyst_sendRawTransaction(0x)` → returns `0x` + - `catalyst_getTransactionReceipt(0x)` → receipt with `status` and `success` + - `catalyst_getCode(0x)` → `0x...` code +- Transaction type for EVM interactions is `TransactionType::SmartContract`. +- EVM deploy “bytecode” is **EVM initcode**. Constructor args are appended to initcode. +- Deterministic deployed address uses Ethereum CREATE derivation: + - `evm_sender = last20(pubkey32)` + - `evm_nonce = protocol_nonce - 1` (protocol nonce starts at 1; EVM nonce starts at 0) + +### Repo structure (recommended) + +- `packages/sdk/` + - `src/client.ts` (RPC client) + - `src/domain.ts` (TxDomain fetch + parsing) + - `src/tx/` (builders, signing, encoding, txid) + - `src/evm/` (deploy/call helpers; address derivation) + - `src/index.ts` +- `packages/cli/` + - `src/index.ts` (commander or yargs) + - `src/artifacts.ts` (Foundry/Hardhat artifact parsing) +- `packages/hardhat-plugin/` + - Hardhat tasks: `catalyst:deploy`, `catalyst:call` +- `packages/examples/` (optional) + - Minimal deploy + call example + +### Artifact parsing requirements + +Support these common shapes: + +- Foundry: + - `out/.sol/.json` + - `bytecode.object` is a hex string +- Hardhat: + - `artifacts/contracts/.../.json` + - `bytecode` may be a hex string + +If the artifact lacks bytecode, fail with a clear error. + +### Testing (must include) + +- Unit tests: + - Domain parsing + - Artifact bytecode extraction + - Address derivation correctness +- Integration test (can be optional behind env var): + - Requires `CATALYST_RPC_URL` and `CATALYST_KEY_HEX` + - Deploy a tiny contract and assert `getCode != 0x` + +### Deliverables checklist + +- `README.md` with: + - Quickstart: deploy from Foundry artifact + - Quickstart: deploy from Hardhat artifact + - Explanation of `wallet.key` format + - How deterministic contract addresses are computed +- Published packages (local build is fine for now): + - `@catalyst/sdk` + - `catalyst-deploy` (bin) + - `hardhat-catalyst` + diff --git a/docs/evm-deploy.md b/docs/evm-deploy.md new file mode 100644 index 0000000..217e5af --- /dev/null +++ b/docs/evm-deploy.md @@ -0,0 +1,62 @@ +## EVM contract deployment (Catalyst) + +Catalyst supports EVM smart contracts, but deployments are submitted as **Catalyst CTX2 transactions** via `catalyst_sendRawTransaction` (not Ethereum `eth_sendRawTransaction`). + +### Quickstart (CLI) + +Deploy from a raw hex file (or raw bytes) containing initcode: + +```bash +./target/release/catalyst-cli deploy \ + --runtime evm \ + --key-file wallet.key \ + --rpc-url https://YOUR-RPC \ + --wait --verify-code \ + path/to/bytecode.hex +``` + +Deploy from a Foundry or Hardhat artifact JSON: + +```bash +./target/release/catalyst-cli deploy \ + --runtime evm \ + --key-file wallet.key \ + --rpc-url https://YOUR-RPC \ + --wait --verify-code \ + out/MyContract.sol/MyContract.json +``` + +### Constructor args + +`catalyst-cli deploy --args` expects **hex bytes** to append to the contract initcode. + +Example: + +```bash +./target/release/catalyst-cli deploy \ + --runtime evm \ + --key-file wallet.key \ + --rpc-url https://YOUR-RPC \ + --args 0x \ + --wait --verify-code \ + out/MyContract.sol/MyContract.json +``` + +### How the deploy address is derived + +Catalyst uses the Ethereum `CREATE` derivation: + +- `evm_sender = last20(sender_pubkey32)` +- `evm_nonce = protocol_nonce - 1` (protocol nonces start at 1; EVM nonce starts at 0) +- `contract_address = keccak256(rlp([evm_sender, evm_nonce]))[12..]` + +The CLI prints the computed address as `contract_address: 0x...`. + +### Tooling should use `catalyst_getTxDomain` + +When signing transactions, tooling should fetch the domain in a **single RPC call**: + +- `catalyst_getTxDomain -> { chain_id, genesis_hash, network_id, ... }` + +This avoids signature failures caused by load balancers routing `chainId` and `genesisHash` requests to different backends. + diff --git a/docs/tokenomics-spec.md b/docs/tokenomics-spec.md new file mode 100644 index 0000000..a446f96 --- /dev/null +++ b/docs/tokenomics-spec.md @@ -0,0 +1,231 @@ +# Tokenomics specification (proposed; editable) + +This document is intended to be **modified by humans** and then used as the canonical source for implementing: +- **#185**: economics spec alignment (this doc) +- **#184**: economics implementation (fees, rewards, issuance, anti-spam) + +It is structured as: +- **Parameters**: the values that must be chosen (with recommendations) +- **Mechanisms**: how fees/issuance/rewards work conceptually +- **Invariants + tests**: what must be true for determinism and safety + +## Status quo (code today) + +- Balances are integer amounts stored under `bal:` (unit is an unlabelled “atom”). +- Transfers are validated with a basic “no negative balances” rule. +- Transactions include a `fees` field and the node enforces a deterministic **minimum fee schedule**: + - `crates/catalyst-core/src/protocol.rs`: `min_fee_for_core(...)` +- EVM executes with `gas_price = 0` and fee charging is handled (or will be handled) at the protocol layer. +- There is **no** issuance schedule, staking/rewards, inflation, or burn/treasury logic implemented yet. + +## Goals + +- **Deterministic**: starting from genesis, all honest nodes compute identical balances/supply. +- **Anti-spam**: sustained junk tx submission must be economically bounded. +- **Operationally safe**: parameters are bounded/validated; changes are treated as coordinated upgrades. +- **Wallet/dev UX**: fee estimation is straightforward and stable across upgrades. + +## Terminology + +- **ATOM**: the smallest indivisible unit of the native token (integer). +- **TOKEN**: human unit (e.g. \(10^9\) ATOM = 1 TOKEN). This doc proposes a decimal scheme below. +- **Burn**: removing tokens from circulation by sending to an unspendable sink. +- **Supply**: + - **total supply**: sum of all balances + any accounted “burn” (depending on accounting choice) + - **circulating supply**: total supply minus locked/unspendable accounts (if any) + +## Parameter table (values to decide) + +### 1) Token identity (product / ecosystem) + +- **token_name**: `TBD` (e.g. “Catalyst”) +- **token_symbol**: `TBD` (e.g. “CAT”) +- **token_decimals**: **recommended** `9` + - Rationale: consistent with many systems; enough precision without huge numbers. + +### 2) Supply model (economic policy) + +Choose one of these as the **mainnet** policy: + +**Option A — Fair launch constant emission (your stated preference)** +- **genesis_supply_tokens**: **1 TOKEN** (only) +- **block_reward_tokens**: **1 TOKEN per block** +- **premine / treasury**: **none** +- **reward_recipients**: producers (details below) + +Important implementation note: in current Catalyst code and docs, “block” corresponds to the **consensus cycle** / applied LSU head (`applied_cycle`). This spec therefore treats: +\[ +\text{blocks per year} \approx \frac{365\cdot 24\cdot 60\cdot 60}{\text{cycle\_duration\_seconds}} +\] +and total emitted supply is deterministic from the number of applied cycles. + +Concrete examples (with `block_reward_tokens = 1`): + +| `cycle_duration_seconds` | blocks/year | new tokens/year | +|---:|---:|---:| +| 1 | 31,536,000 | 31,536,000 | +| 10 | 3,153,600 | 3,153,600 | +| 20 | 1,576,800 | 1,576,800 | +| 60 | 525,600 | 525,600 | + +Pros: aligns with “earn by running nodes”; avoids premine distortions; extremely simple supply rule. +Cons: **Sybil incentive** (many identities → more chances at rewards) unless producer selection has a strong anti-sybil gate. + +**Option B — Fixed supply (no emission)** +- **initial_total_supply_atoms**: `TBD` +- **block_reward_tokens**: `0` + +Pros: minimal monetary governance. +Cons: incentives become fee-only; weak early network bootstrapping. + +**Option C — Low inflation / capped schedule** +- Define issuance schedule (per-cycle or per-year) and deterministic recipients. + +Pros: flexible incentives. +Cons: more parameters and governance complexity. + +**Option D — Adaptive issuance** (not recommended initially) +Inflation reacts to participation/uptime. + +### 3) Fee model (protocol-level anti-spam) + +This repo already has a deterministic minimum fee schedule. The spec should decide: + +- **fee_denomination**: ATOM +- **min_fee_schedule_v1**: + - **base_fee_by_tx_type** (ATOM): `TBD` + - suggested starting point (close to code today): + - transfer: `1` + - smart_contract: `5` + - worker_registration: `1` + - **per_entry_fee** (ATOM): suggested `1` + - **per_byte_fee** (ATOM/byte): suggested `0` for testnet, `>=1` for mainnet if large payloads are a concern + - **max_tx_wire_bytes**: already bounded in code; keep aligned with networking limits + +**Fee charging rule** (recommended): +At apply-time, require `tx.fees >= min_fee(tx)` and then: +- debit `tx.fees` from sender (or from the transaction’s funding entries if multi-entry semantics) +- credit according to `fee_routing_policy` (below) + +### 4) Fee routing policy (burn vs treasury vs rewards) + +Decide a single deterministic policy for v1: + +- **fee_burn_bps**: **recommended** `10000` (burn all fees) +- **fee_route_to_producers_bps**: **recommended** `0` initially + +Rationale: with **no treasury**, the simplest non-custodial policy is to **burn fees** (anti-spam without a custodian). +If desired later, fees can be routed to producers to further reward block production. + +### 5) Rewards (validators/producers/workers) + +If **Option A (fair launch constant emission)** is chosen, define precisely: + +- **block_reward_tokens**: `1 TOKEN` (fixed) +- **reward_event**: “on successful cycle application” (i.e. when a new LSU is applied and `applied_cycle` increments) +- **reward_recipients_rule_v1** (choose one): + +**Rule 1 — Cycle leader only (simplest)** +Pick a deterministic leader for cycle \(n\) (e.g. producer set index 0, or the one that finalizes the LSU) and credit `1 TOKEN` to that leader’s account. + +Pros: simplest implementation. +Cons: concentrates rewards if leadership is sticky; incentives for leader targeting. + +**Rule 2 — Split equally among the cycle’s producer set (recommended)** +If the protocol defines a producer set for cycle \(n\), split the `1 TOKEN` equally across the set (integer division rules must be specified; remainder burned). + +Pros: aligns “anyone running nodes” with broad distribution; less leader centralization. +Cons: requires a well-defined producer set at apply-time; needs careful rounding rules. + +**Rule 3 — Split among witnessed contributors** +Split among the witness list / participants that contributed to finalization. + +Pros: closer to “pay for participation”. +Cons: more complex; needs stable witness definitions. + +Security note (critical): If rewards are paid based on being selected as a producer, then **producer selection must be Sybil-resistant** (otherwise attackers can flood the worker pool and capture emissions). This spec therefore needs a clear anti-sybil gate, such as: +- stake/bond (not compatible with “no premine” unless bonds are earned over time), +- proof-of-resource / proof-of-work style gating for worker registration, +- strict per-IP/subnet caps + reputation + long-lived identity requirements, +- permissioned validator sets for phase-0 mainnet (less ideal for your stated goals). + +Important: reward rules must map cleanly onto the existing consensus cycle model to stay deterministic. + +### 6) Genesis funding (no premine) + +- **genesis_supply_tokens**: `1 TOKEN` total supply at genesis +- **genesis_recipient**: `TBD` (who gets the 1 token?) + - recommendation: a well-known “genesis owner” key that can only be used for operational actions, or an unspendable sink if the 1 token is purely symbolic. + - if the 1 token is intended to fund initial tx fees, it must belong to a real key. + +Recommendation: +- **Testnet**: keep the faucet mechanism for UX. +- **Mainnet**: faucet disabled; all supply comes from block rewards. + +### 7) Mempool admission (anti-spam enforcement points) + +Define where fees are enforced: + +- **mempool_min_fee_enforced**: recommended `true` (reject at admission if `tx.fees < min_fee`) +- **apply_time_fee_enforced**: required `true` (final enforcement) +- **rate limits**: handled separately (P2P/RPC safety limits) + +### 8) EVM fee mapping (when EVM gas becomes meaningful) + +Current approach is protocol fee charging with EVM `gas_price = 0`. For v1: + +- **evm_fee_mode**: recommended `FlatFeeByTxType` (use `min_fee_schedule_v1`) + +Future (v2): +- **GasToFee**: charge based on gas used: + - `fee_atoms = gas_used * gas_price_atoms_per_gas` + - optional basefee dynamics (EIP-1559-like) if desired + +## Recommended baseline (what I would start with) + +### Public testnet baseline (simple + safe) + +- **Supply**: use the same fair-launch emission model or keep testnet relaxed; faucet can exist for UX. +- **Fees**: keep current deterministic minimum fee schedule (small but non-zero). +- **Fee routing**: burn fees (no treasury). +- **Rewards**: can be enabled if you want testnet to match mainnet; otherwise testnet may diverge from mainnet economics. + +### Early mainnet baseline (v1 mainnet) + +Per your goals, recommended v1 mainnet baseline: +- Choose **Option A (fair launch constant emission)**. +- Set `genesis_supply_tokens = 1` and `block_reward_tokens = 1`. +- Enable fees with conservative floors (anti-spam) and **burn** them initially. +- Use **reward rule 2** (split across producer set) if feasible; otherwise rule 1 (leader only) is acceptable as a first implementation. +- Prioritize Sybil-resistance in worker registration / producer selection as a launch blocker (otherwise emissions can be captured cheaply). + +## Invariants (must be test-covered) + +- **No negative balances** after applying any cycle. +- **Deterministic supply**: + - total supply at cycle \(n\) is a pure function of genesis + all applied LSUs up to \(n\) +- **Fee determinism**: + - `min_fee(tx)` is deterministic and versioned (changing it requires an explicit protocol bump) +- **Conservation**: + - for transfers (excluding fees/rewards/issuance), sums net to zero + - fees are either burned or routed deterministically; never “disappear” +- **Replay safety**: + - same tx cannot be applied twice (nonce rules + txid indexing) + +## Implementation mapping (how this becomes code) + +- **Protocol constants and fee schedule**: `crates/catalyst-core/src/protocol.rs` +- **Apply-time accounting**: LSU application path (storage/node apply) +- **RPC surfaces**: + - fee parameters (for wallets to estimate) + - supply/issuance stats (for explorers) + +## Open questions checklist (fill these in) + +1) Token name/symbol/decimals: `TBD` +2) Genesis recipient of the 1 token (or sink): `TBD` +3) Reward recipients rule (leader-only vs producer-set split vs witness split): `TBD` +4) Sybil-resistance mechanism for worker registration / producer selection: `TBD` (launch blocker) +5) Do we want per-byte fees at launch? `TBD` +6) Should fees be burned forever, or partially routed to producers later? `TBD` +