iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit d6f4b5724e84c15166e7e0e0f6164a12bbdf0015
parent 0fe5f6d8b6ad8d09c4a9d2e4ead9cb3d7bc262aa
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 22 Jul 2026 11:33:32 +0200

Harden consensus and refresh node UI


Diffstat:
MREADME.md | 12++++++------
Massets/mivora-ui.js | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mdevlogs/001-node-first.md | 10+++++-----
Msrc/adapters/chain_store.rs | 2+-
Msrc/adapters/http.rs | 257++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Msrc/adapters/p2p.rs | 21+++++++++++----------
Msrc/app.rs | 21++++++++++++++++-----
Msrc/domain.rs | 465+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/main.rs | 6+++---
Mtests/coin.rs | 162++++++++++++++++++++++++++++++++++++++++---------------------------------------
10 files changed, 758 insertions(+), 309 deletions(-)

diff --git a/README.md b/README.md @@ -13,9 +13,9 @@ cargo run -- --start --http 127.0.0.1:8443 --p2p 127.0.0.1:9444 Open `http://127.0.0.1:8443`. The wallet is generated into `.mivora/`. The validated chain is persisted to `.mivora/chain.sqlite3` and resumes automatically when the same data directory is started again. -Mining is automatic. There is no "mine block" button and no exact sleep. Each node burns its configured amount once per chain height, then only the VRF-selected leader builds a block containing burned coins, performs the VDF work, and gossips the finished block. The VDF is the clock. +Mining is automatic. There is no "mine block" button and no exact sleep. Each node can burn its configured amount once per chain height. Those burns become one-shot leader tickets for a future height after the launch profile's maturity delay. Only the selected ticket owner builds the next block, signs a leader proof, performs the VDF work, and gossips the finished block. The VDF is the clock. -The plain command above creates the default zero-balance starter chain: genesis mints 1 coin and immediately burns it, selecting the starter as the first leader. Because non-genesis blocks must include a positive burn, that chain will wait until a wallet has spendable coins to burn. For a self-running local demo, leave one extra coin after genesis and burn it into the first block: +The plain command above creates the default zero-balance starter chain: genesis mints 1 coin and immediately burns it into the first leader ticket. That is enough to mine block 1 and earn the first reward. For a self-running local demo, leave one extra coin after genesis and burn it into block 1 so block 2 already has a ticket: ```sh cargo run -- --start --genesis-amount 2 --burn-per-block 1 --http 127.0.0.1:8443 --p2p 127.0.0.1:9444 @@ -59,11 +59,11 @@ cargo run -- --start --genesis-amount 2 --burn-per-block 1 --p2p 0.0.0.0:9444 -- cargo run -- --data-dir .mivora-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:8443 --join your-host:9444 ``` -Friends who join after you start will adopt your genesis and current chain. With the default genesis amount, the starter wallet begins with a 0 balance because genesis mints 1 coin and immediately burns it as the first lottery ticket. For a moving demo, `--genesis-amount 2 --burn-per-block 1` leaves the starter one coin to burn into block 1. After the starter mines the first block reward, send friends coins from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-coin automatic burn unless it is configured otherwise. +Friends who join after you start will adopt your genesis and current chain. With the default genesis amount, the starter wallet begins with a 0 balance because genesis mints 1 coin and immediately burns it as the first leader ticket. For a moving demo, `--genesis-amount 2 --burn-per-block 1` leaves the starter one coin to burn into block 1, creating the ticket for block 2. After the starter mines the first block reward, send friends coins from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-coin automatic burn unless it is configured otherwise. -The genesis block bootstraps the chain with a 1-coin burn from the starter wallet. Burns included in the latest block select the leader for the next block through a deterministic VRF-style lottery. The selected leader creates the next block content and runs a hash-chain VDF before gossiping the block. +The genesis block bootstraps the chain with a 1-coin burn from the starter wallet. Burns included in a block create one-shot tickets for a future height through a deterministic ticket lottery. The selected leader creates the next block content, signs a proof for the selected ticket, and runs a hash-chain VDF before gossiping the block. -Every non-genesis block must include at least one positive burn. Blocks without burns are rejected because they would leave the next height without lottery tickets. The VDF input is the pre-proof hash of the candidate block content, so changing the miner, timestamp, reward, rounds, previous hash, or transactions requires rerunning the VDF. +Every non-genesis block must consume the selected mature ticket. A block may contain zero burns, but then it does not create future tickets. The VDF seed is bound to the parent hash and child height; the block hash separately commits to the miner, timestamp, reward, rounds, previous hash, leader proof, VDF output, and transactions. The protocol targets 60-second blocks by retargeting the expected VDF rounds after each block. It uses a rolling average of recent block intervals and only moves the next round count by about 10% per block, so short bursts do not make the delay swing wildly. Every node derives the same next-round count from the validated chain. @@ -91,7 +91,7 @@ Mivora stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chai ## Architecture -- `src/domain.rs`: wallet, transactions, balances, genesis burn bootstrap, fixed 100-coin rewards, blocks, burn lottery, and VDF checks. +- `src/domain.rs`: wallet, transactions, balances, genesis burn bootstrap, fixed 100-coin rewards, blocks, mature leader tickets, leader proofs, fork choice, launch profile, and VDF checks. - `src/app.rs`: node use cases, automatic VDF-paced mining, peer bookkeeping, and an in-memory network harness. - `src/adapters/http.rs`: HTTP management UI and status endpoint. - `src/adapters/p2p.rs`: line-delimited JSON gossip, block-range catch-up, and chain snapshots over one TCP port. diff --git a/assets/mivora-ui.js b/assets/mivora-ui.js @@ -12,18 +12,37 @@ window.mivoraApp = function mivoraApp() { transferTo: "", transferAmount: 25, peerAddress: "", + showBurnTransactions: true, flash: null, flashTimer: null, lastUpdated: null, pollHandle: null, newBlockHashes: new Set(), newBlockTimer: null, + blockPageSize: 20, init() { + this.tab = this.tabFromHash(); + window.addEventListener("hashchange", () => { + this.tab = this.tabFromHash(); + }); this.refresh(); this.pollHandle = setInterval(() => this.refresh(), 5000); }, + tabFromHash() { + const hash = window.location.hash.replace(/^#\/?/, ""); + return ["wallet", "mining", "p2p", "chain"].includes(hash) ? hash : "wallet"; + }, + + setTab(tab) { + if (!["wallet", "mining", "p2p", "chain"].includes(tab)) return; + this.tab = tab; + if (window.location.hash !== `#${tab}`) { + window.location.hash = tab; + } + }, + async refresh() { try { const [status, blocks, mempool, peers] = await Promise.all([ @@ -72,7 +91,9 @@ window.mivoraApp = function mivoraApp() { } else { this.selectedBlock = known.get(this.selectedBlock.hash); } - this.hasMoreBlocks = this.blocks.some((block) => block.height > 0); + this.hasMoreBlocks = + this.blocks.some((block) => block.height > 0) && + !this.blocks.some((block) => block.height === 0); const newHeadBlocks = options.animateHead ? this.blocks.filter( @@ -87,6 +108,7 @@ window.mivoraApp = function mivoraApp() { this.slideNewHeadBlocks(previousScrollWidth, { force: wasFollowingHead }) ); } + this.$nextTick(() => this.maybeLoadOlderBlocksFromRail()); }, markNewBlocks(hashes) { @@ -122,8 +144,14 @@ window.mivoraApp = function mivoraApp() { } this.loadingOlder = true; try { - const older = await this.fetchJson(`/api/blocks?before_height=${oldest}&limit=30`); - if (older.length === 0 || older.some((block) => block.height === 0)) { + const older = await this.fetchJson( + `/api/blocks?before_height=${oldest}&limit=${this.blockPageSize}` + ); + if ( + older.length === 0 || + older.length < this.blockPageSize || + older.some((block) => block.height === 0) + ) { this.hasMoreBlocks = false; } this.mergeFreshBlocks(older); @@ -135,9 +163,13 @@ window.mivoraApp = function mivoraApp() { }, maybeLoadOlderBlocks(event) { - const rail = event.currentTarget; + this.maybeLoadOlderBlocksFromRail(event.currentTarget); + }, + + maybeLoadOlderBlocksFromRail(rail = this.$refs.blockRail) { + if (this.tab !== "chain" || !rail || this.loadingOlder || !this.hasMoreBlocks) return; const remaining = rail.scrollWidth - rail.scrollLeft - rail.clientWidth; - if (remaining < 280) { + if (remaining <= 180) { this.loadOlderBlocks(); } }, @@ -197,6 +229,15 @@ window.mivoraApp = function mivoraApp() { } }, + async copyAddress() { + try { + await navigator.clipboard.writeText(this.status.wallet_address || ""); + this.showFlash("Address copied", "success"); + } catch (error) { + this.showFlash("Could not copy address", "error"); + } + }, + showFlash(message, kind) { this.flash = { message, kind }; if (this.flashTimer) { @@ -238,6 +279,61 @@ window.mivoraApp = function mivoraApp() { return `${count} transfer${count === 1 ? "" : "s"}`; }, + walletTransactions() { + const wallet = this.status.wallet_address; + if (!wallet) return []; + + const rows = []; + for (const [index, tx] of this.mempool.entries()) { + if (!this.walletTxMatches(tx, wallet)) continue; + rows.push(this.walletTxRow(tx, { + status: "pending", + blockHeight: null, + sortKey: Number.MAX_SAFE_INTEGER - index, + })); + } + + for (const block of this.blocks) { + const transactions = [...block.transactions].reverse(); + for (const [index, tx] of transactions.entries()) { + if (!this.walletTxMatches(tx, wallet)) continue; + rows.push(this.walletTxRow(tx, { + status: "confirmed", + blockHeight: block.height, + sortKey: block.height * 10_000 + index, + })); + } + } + + return rows + .filter((row) => this.showBurnTransactions || row.kind !== "burn") + .sort((left, right) => right.sortKey - left.sortKey); + }, + + walletTxMatches(tx, wallet) { + return tx.from === wallet || tx.to === wallet; + }, + + walletTxRow(tx, meta) { + const wallet = this.status.wallet_address; + let direction = "sent"; + if (tx.kind === "burn") { + direction = "burn"; + } else if (tx.to === wallet) { + direction = "received"; + } + return { + ...tx, + ...meta, + direction, + }; + }, + + txTitle(tx) { + if (tx.status === "pending") return "Pending"; + return tx.blockHeight === null ? "Confirmed" : `Block ${tx.blockHeight}`; + }, + isLeaderLabel() { if (!this.status.mining) return "-"; return this.status.mining.wallet_is_current_leader ? "yes" : "no"; @@ -259,11 +355,6 @@ window.mivoraApp = function mivoraApp() { return ms ? `${Math.round(ms / 1000)}s` : "-"; }, - olderButtonLabel() { - if (this.loadingOlder) return "Loading"; - return this.hasMoreBlocks ? "Load older blocks" : "Genesis loaded"; - }, - lastUpdatedLabel() { return this.lastUpdated ? `Updated ${this.lastUpdated.toLocaleTimeString()}` : "Loading"; }, diff --git a/devlogs/001-node-first.md b/devlogs/001-node-first.md @@ -1,6 +1,6 @@ # Devlog 001: Node First -Pakala started with a mountain of explanation before there was much to run. Mivora starts in the opposite direction. +Mivora starts from a running node first, then lets the explanation grow around the code. The first version is a single binary: wallet, node, miner, HTTP management UI, and P2P listener all in one place. It is not trying to survive hostile internet conditions yet. It is trying to make the coin feel alive as quickly as possible. @@ -8,10 +8,10 @@ The important design choice is the hexagonal split. The coin rules live in the d The consensus sketch is intentionally small: -- burn coins into the latest block, -- use those burns as lottery tickets for the next block, -- delay the draw with a simple verifiable hash-chain VDF, -- give the selected wallet the right to mine the next block, +- burn coins into a block, +- turn those burns into mature one-shot tickets for future blocks, +- use parent-bound VDF work as the pacing signal, +- give the selected ticket owner the signed right to mine the next block, - forget the stake because the coins were already burned. That gives us something real to poke at now, while leaving plenty of room to make the cryptography and networking less toy-like later. diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -163,7 +163,7 @@ mod tests { let burn = wallet.burn(1, ledger.next_nonce(wallet.address())); ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(wallet.address(), 1_000).unwrap(); + let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); ledger.apply_locally_mined_block(block).unwrap(); store.save(&ledger.snapshot()).unwrap(); diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -18,7 +18,7 @@ use crate::{ }; const EXPLORER_LIMIT: usize = 50; -const EXPLORER_PAGE_LIMIT: usize = 30; +const EXPLORER_PAGE_LIMIT: usize = 20; #[derive(Clone)] struct HttpState { @@ -246,105 +246,215 @@ const INDEX_HTML: &str = r#"<!doctype html> <title>Mivora</title> <style> [x-cloak] { display: none !important; } - :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } - body { margin: 0; background: #f7f8fa; color: #17202a; } - main { max-width: 1180px; margin: 0 auto; padding: 18px 18px 48px; } - header { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; padding: 0 0 16px; border-bottom: 1px solid #d9e0e7; } - h1 { margin: 0 0 4px; font-size: 26px; } + :root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0f1012; + color: #e8edf0; + } + * { box-sizing: border-box; } + body { margin: 0; min-height: 100vh; background: #0f1012; color: #e8edf0; } + .app-shell { min-height: 100vh; display: grid; grid-template-columns: 84px minmax(0, 1fr); } + .sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 16px 10px; background: #15171a; border-right: 1px solid #262b2f; } + .brand-mark { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 8px; background: #d5f55f; color: #11140c; font-size: 22px; font-weight: 900; } + .side-nav { display: grid; gap: 10px; width: 100%; } + .nav-button { width: 64px; min-height: 58px; display: grid; place-items: center; gap: 4px; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; background: transparent; color: #9fa8ad; } + .nav-button svg { width: 21px; height: 21px; stroke: currentColor; stroke-width: 2; fill: none; } + .nav-button svg.chain-icon { stroke-width: 1.35; } + .nav-button span { font-size: 11px; font-weight: 800; } + .nav-button:hover, .nav-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } + .content { min-width: 0; padding: 22px 24px 48px; } + main { max-width: 1240px; margin: 0 auto; } + header { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; padding: 0 0 18px; } + h1 { margin: 0 0 4px; font-size: 28px; } h2 { margin: 0 0 12px; font-size: 18px; } h3 { margin: 0 0 10px; font-size: 15px; } - button { border: 1px solid #c9d2dc; border-radius: 6px; padding: 8px 11px; font: inherit; font-weight: 700; background: white; color: #17202a; cursor: pointer; } - button:hover { border-color: #157a6e; color: #0f665d; } - button.primary { background: #116149; border-color: #116149; color: white; } - button.primary:hover { background: #0b4f3b; color: white; } - button:disabled { cursor: default; opacity: .55; } - .tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0; } - .tabs button.active { background: #17202a; border-color: #17202a; color: white; } - .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } - .metric, .panel { background: white; border: 1px solid #dde3ea; border-radius: 8px; padding: 12px; } - .metric .label { color: #667789; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; } - .metric .value { margin-top: 6px; font-weight: 800; overflow-wrap: anywhere; } + button { border: 1px solid #3a4248; border-radius: 6px; padding: 8px 11px; font: inherit; font-weight: 700; background: #191c20; color: #e8edf0; cursor: pointer; } + button:hover { border-color: #d5f55f; color: #d5f55f; } + button.primary { background: #d5f55f; border-color: #d5f55f; color: #15171a; } + button.primary:hover { background: #e4ff83; color: #15171a; } + button:disabled { cursor: default; opacity: .5; } + .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } + .metric, .panel { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 13px; } + .metric .label { color: #8d989f; font-size: 11px; text-transform: uppercase; } + .metric .value { margin-top: 7px; font-weight: 850; overflow-wrap: anywhere; } .panel { margin-bottom: 12px; } - .split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, .7fr); gap: 12px; } + .split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr); gap: 12px; } form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } - label { display: grid; gap: 5px; color: #465564; font-size: 13px; } - input { min-width: 180px; border: 1px solid #b8c4cf; border-radius: 6px; padding: 9px 10px; font: inherit; background: white; } + label { display: grid; gap: 5px; color: #a8b2b8; font-size: 13px; } + input { min-width: 180px; border: 1px solid #3a444b; border-radius: 6px; padding: 9px 10px; font: inherit; background: #101215; color: #edf2f5; } + input:focus { outline: 2px solid #d5f55f; outline-offset: 1px; } table { width: 100%; border-collapse: collapse; font-size: 13px; } - th, td { text-align: left; border-bottom: 1px solid #e2e7ed; padding: 8px; vertical-align: top; } - th { color: #667789; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; } - code { overflow-wrap: anywhere; } + th, td { text-align: left; border-bottom: 1px solid #2a3035; padding: 8px; vertical-align: top; } + th { color: #8d989f; font-size: 11px; text-transform: uppercase; } + code { overflow-wrap: anywhere; color: #c7f5ea; } .table-wrap { overflow-x: auto; } - .muted { color: #667789; } + .muted { color: #8d989f; } .flash { border-radius: 6px; padding: 10px 12px; margin: 12px 0; border: 1px solid; font-weight: 700; } - .flash.success { color: #0b5e43; background: #effbf4; border-color: #a7dfbd; } - .flash.error { color: #9b1c1c; background: #fff1f1; border-color: #f0b7b7; } - .ok { color: #0b5e43; } - .summary-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; } + .flash.success { color: #d5f55f; background: #1c2516; border-color: #566d25; } + .flash.error { color: #ffb1a8; background: #2a1717; border-color: #713434; } + .ok { color: #d5f55f; } + .page-title { margin-bottom: 16px; } + .wallet-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } + .wallet-actions { display: grid; gap: 12px; } + .mining-grid { display: grid; grid-template-columns: minmax(0, .95fr) minmax(300px, .7fr); gap: 12px; align-items: start; } + .receive-address { display: grid; gap: 8px; } + .address-box { border: 1px solid #2f363c; border-radius: 8px; padding: 11px; background: #111316; } + .panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } + .panel-head h2, .panel-head h3 { margin-bottom: 0; } + .switch { display: inline-flex; grid-template-columns: none; align-items: center; gap: 8px; color: #d6dee2; font-weight: 700; } + .switch input { width: auto; min-width: 0; accent-color: #d5f55f; } + .wallet-tx-list { display: grid; gap: 8px; } + .wallet-tx-row { display: grid; grid-template-columns: minmax(88px, .35fr) minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + .wallet-tx-main { display: grid; gap: 4px; min-width: 0; } + .wallet-tx-amount { font-weight: 900; } + .panel .grid + form { margin-top: 12px; } .explorer-shell { display: grid; gap: 12px; } - .block-rail-wrap { background: white; border: 1px solid #dde3ea; border-radius: 8px; padding: 12px; overflow: hidden; } + .block-rail-wrap { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; overflow: hidden; } .block-rail-head { display: flex; justify-content: space-between; gap: 10px; align-items: center; margin-bottom: 10px; } .block-rail { display: flex; gap: 8px; overflow-x: auto; padding: 1px 0 10px; scroll-snap-type: x proximity; } - .block-card { flex: 0 0 118px; min-height: 96px; display: grid; gap: 6px; border: 1px solid #dde3ea; border-radius: 8px; padding: 9px; background: #fbfcfd; color: #17202a; text-align: left; scroll-snap-align: start; } - .block-card:hover { border-color: #8bbdb5; color: #0f665d; } - .block-card.selected { background: #eef8f5; border-color: #157a6e; box-shadow: inset 0 0 0 1px #157a6e; } + .block-card { flex: 0 0 122px; min-height: 100px; display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 9px; background: #111316; color: #e8edf0; text-align: left; scroll-snap-align: start; } + .block-card:hover { border-color: #d5f55f; color: #d5f55f; } + .block-card.selected { background: #202616; border-color: #d5f55f; box-shadow: inset 0 0 0 1px #d5f55f; } .block-card.new-block { animation: block-arrive .45s ease both; } @keyframes block-arrive { from { opacity: .2; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } } .block-height { font-size: 18px; font-weight: 900; } - .block-meta { display: flex; gap: 8px; color: #667789; font-size: 12px; } - .block-hash { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; color: #465564; } - .rail-actions { display: flex; justify-content: flex-end; padding-top: 2px; } + .block-meta { display: flex; gap: 8px; color: #8d989f; font-size: 12px; } + .block-hash { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; color: #9eb3bc; } + .skeleton-card { pointer-events: none; position: relative; overflow: hidden; } + .skeleton-card::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(213, 245, 95, .12), transparent); animation: skeleton-sweep 1.15s ease-in-out infinite; } + @keyframes skeleton-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } } + .skeleton-line { height: 12px; border-radius: 6px; background: #2b3136; } + .skeleton-line.short { width: 42%; } + .skeleton-line.medium { width: 68%; } + .skeleton-line.long { width: 88%; } .detail-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 12px; } .detail-kv { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 8px; font-size: 13px; margin: 7px 0; } - .detail-kv .key { color: #667789; } + .detail-kv .key { color: #8d989f; } .tx-list { display: grid; gap: 8px; } - .tx-card { border: 1px solid #e2e7ed; border-radius: 8px; padding: 10px; background: #fbfcfd; } + .tx-card { border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } .tx-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-weight: 800; } - .pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; background: #e8eef4; color: #34495e; } - .pill.burn { background: #fff0d9; color: #845400; } - .pill.transfer { background: #e5f5ee; color: #0b5e43; } + .pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; background: #2b3136; color: #d6dee2; } + .pill.burn { background: #332918; color: #ffd070; } + .pill.transfer { background: #17312a; color: #8de9cd; } .mempool-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; } - .mempool-item { flex: 0 0 200px; border: 1px solid #e2e7ed; border-radius: 8px; padding: 10px; background: white; } - @media (max-width: 920px) { .summary-row, .detail-grid { grid-template-columns: 1fr 1fr; } } - @media (max-width: 760px) { .split, .summary-row, .detail-grid { grid-template-columns: 1fr; } input { min-width: 0; width: 100%; } .block-card { flex-basis: 108px; } } + .mempool-item { flex: 0 0 200px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + @media (max-width: 920px) { .wallet-grid, .mining-grid, .detail-grid { grid-template-columns: 1fr; } } + @media (max-width: 760px) { + .app-shell { grid-template-columns: 1fr; } + .sidebar { position: sticky; z-index: 5; bottom: 0; top: auto; height: auto; flex-direction: row; justify-content: space-between; padding: 8px; border-right: 0; border-bottom: 1px solid #262b2f; } + .brand-mark { width: 38px; height: 38px; font-size: 19px; } + .side-nav { display: flex; width: auto; gap: 8px; } + .nav-button { width: 58px; min-height: 48px; } + .content { padding: 16px 12px 36px; } + header, .split, .wallet-grid, .mining-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; } + header { display: grid; } + input { min-width: 0; width: 100%; } + .switch input { width: auto; } + .block-card { flex-basis: 108px; } + } </style> - <script defer src="/assets/mivora-ui.js?v=9"></script> + <script defer src="/assets/mivora-ui.js?v=13"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="mivoraApp()" x-init="init()" x-cloak> - <main> + <div class="app-shell"> + <aside class="sidebar" aria-label="Mivora navigation"> + <div class="brand-mark" title="Mivora">M</div> + <nav class="side-nav"> + <button class="nav-button" :class="{ active: tab === 'wallet' }" @click="setTab('wallet')" type="button" title="Wallet" aria-label="Wallet"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H3z"></path><path d="M3 7V5a2 2 0 0 1 2-2h12"></path><path d="M16 13h3"></path></svg> + <span>Wallet</span> + </button> + <button class="nav-button" :class="{ active: tab === 'mining' }" @click="setTab('mining')" type="button" title="Mining" aria-label="Mining"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l4-4 3 3 5-7"></path></svg> + <span>Mining</span> + </button> + <button class="nav-button" :class="{ active: tab === 'p2p' }" @click="setTab('p2p')" type="button" title="P2P" aria-label="P2P"> + <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="6" r="3"></circle><circle cx="18" cy="18" r="3"></circle><path d="M8.5 10.5 15.5 7.5"></path><path d="M8.5 13.5 15.5 16.5"></path></svg> + <span>P2P</span> + </button> + <button class="nav-button" :class="{ active: tab === 'chain' }" @click="setTab('chain')" type="button" title="Explorer" aria-label="Explorer"> + <svg class="chain-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="1.5" y="9" width="5.5" height="5.5"></rect><rect x="9.25" y="9" width="5.5" height="5.5"></rect><rect x="17" y="9" width="5.5" height="5.5"></rect></svg> + <span>Chain</span> + </button> + </nav> + </aside> + + <main class="content"> <header> <div> <h1>Mivora</h1> - <div class="muted">Burn lottery devnet</div> + <div class="muted">Consensus node console</div> </div> <div class="muted" x-text="lastUpdatedLabel()"></div> </header> <div class="flash" :class="flash?.kind" x-show="flash" x-transition x-text="flash?.message"></div> - <section class="summary-row"> - <div class="metric"><div class="label">Node</div><div class="value" x-text="status.name || '-'"></div></div> - <div class="metric"><div class="label">Local Height</div><div class="value" x-text="status.chain?.height ?? '-'"></div></div> - <div class="metric"><div class="label">Shared Height</div><div class="value" x-text="sharedHeightLabel()"></div></div> - <div class="metric"><div class="label">Wallet Balance</div><div class="value" x-text="status.wallet_balance ?? '-'"></div></div> - <div class="metric"><div class="label">Mempool</div><div class="value" x-text="mempool.length"></div></div> + <section x-show="tab === 'wallet'"> + <div class="page-title"> + <h2>Wallet</h2> + <div class="muted">Balance <strong x-text="status.wallet_balance ?? '-'"></strong></div> + </div> + <div class="wallet-grid"> + <div class="wallet-actions"> + <div class="panel"> + <h3>Send</h3> + <form @submit.prevent="sendTransfer"> + <label>Recipient<input x-model="transferTo" autocomplete="off"></label> + <label>Amount<input x-model.number="transferAmount" type="number" min="1"></label> + <button class="primary" type="submit">Send</button> + </form> + </div> + <div class="panel"> + <div class="panel-head"> + <h3>Receive</h3> + <button type="button" @click="copyAddress">Copy</button> + </div> + <div class="receive-address"> + <div class="muted">Public key / address</div> + <div class="address-box"><code x-text="status.wallet_address || '-'"></code></div> + </div> + </div> + </div> + <div class="panel"> + <div class="panel-head"> + <h3>Transactions</h3> + <label class="switch"><input x-model="showBurnTransactions" type="checkbox">Show burns</label> + </div> + <div class="wallet-tx-list"> + <template x-for="tx in walletTransactions()" :key="tx.status + '-' + tx.signature"> + <div class="wallet-tx-row"> + <span class="pill" :class="tx.kind" x-text="tx.direction"></span> + <div class="wallet-tx-main"> + <div><span class="wallet-tx-amount" x-text="tx.amount"></span> coin(s)</div> + <div class="muted" x-text="txTitle(tx)"></div> + <div><span class="muted">from </span><code x-text="short(tx.from)"></code></div> + <div x-show="tx.to"><span class="muted">to </span><code x-text="short(tx.to)"></code></div> + </div> + <code x-text="short(tx.signature)"></code> + </div> + </template> + <div class="muted" x-show="walletTransactions().length === 0">No wallet transactions</div> + </div> + </div> + </div> </section> - <nav class="tabs"> - <button :class="{ active: tab === 'wallet' }" @click="tab = 'wallet'">Wallet</button> - <button :class="{ active: tab === 'p2p' }" @click="tab = 'p2p'">P2P</button> - <button :class="{ active: tab === 'chain' }" @click="tab = 'chain'">Explorer</button> - </nav> - - <section x-show="tab === 'wallet'"> - <div class="split"> + <section x-show="tab === 'mining'"> + <div class="page-title"> + <h2>Mining</h2> + <div class="muted">Automatic VDF-paced block production</div> + </div> + <div class="mining-grid"> <div class="panel"> - <h2>Wallet</h2> - <p><code x-text="status.wallet_address || '-'"></code></p> + <h3>Status</h3> <div class="grid"> - <div class="metric"><div class="label">Balance</div><div class="value" x-text="status.wallet_balance ?? '-'"></div></div> <div class="metric"><div class="label">Current Leader</div><div class="value" x-text="isLeaderLabel()"></div></div> <div class="metric"><div class="label">Last Burn Height</div><div class="value" x-text="status.mining?.last_auto_burn_height ?? '-'"></div></div> + <div class="metric"><div class="label">VDF Rounds</div><div class="value" x-text="status.mining?.vdf_rounds ?? '-'"></div></div> + <div class="metric"><div class="label">Target</div><div class="value" x-text="targetSecondsLabel()"></div></div> </div> </div> <div class="panel"> @@ -355,14 +465,6 @@ const INDEX_HTML: &str = r#"<!doctype html> </form> </div> </div> - <div class="panel"> - <h3>Send Coins</h3> - <form @submit.prevent="sendTransfer"> - <label>Recipient<input x-model="transferTo" autocomplete="off"></label> - <label>Amount<input x-model.number="transferAmount" type="number" min="1"></label> - <button class="primary" type="submit">Send</button> - </form> - </div> </section> <section x-show="tab === 'p2p'"> @@ -404,9 +506,13 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="block-hash" x-text="short(block.hash)"></div> </button> </template> - </div> - <div class="rail-actions"> - <button @click="loadOlderBlocks" :disabled="loadingOlder || !hasMoreBlocks" x-text="olderButtonLabel()"></button> + <template x-if="loadingOlder"> + <div class="block-card skeleton-card" aria-hidden="true"> + <div class="skeleton-line short"></div> + <div class="skeleton-line medium"></div> + <div class="skeleton-line long"></div> + </div> + </template> </div> </div> @@ -458,6 +564,7 @@ const INDEX_HTML: &str = r#"<!doctype html> </section> </div> </section> - </main> + </main> + </div> </body> </html>"#; diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -1134,10 +1134,10 @@ mod tests { use crate::{ app::{ - BlockInventory, GossipEnvelope, NETWORK_ID, NodeConfig, NodeCore, PROTOCOL_VERSION, - PeerBook, PeerDirection, ProtocolHello, + BlockInventory, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, PeerBook, + PeerDirection, ProtocolHello, }, - domain::{Amount, Wallet}, + domain::{Amount, GenesisBurn, Ledger, Wallet}, }; use super::{ @@ -1212,6 +1212,7 @@ mod tests { reward: 100, vdf_rounds: 1, vdf_output: "vdf".to_string(), + leader_proof: None, transactions: Vec::new(), hash: "hash".to_string(), }; @@ -1603,13 +1604,13 @@ mod tests { } fn node(name: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { - NodeCore::new(NodeConfig { - name: name.to_string(), - wallet, - genesis_allocations: allocations, - vdf_rounds: 25, - burn_per_block: 0, - }) + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), 1)], + 25, + ) + .unwrap(); + NodeCore::from_ledger(name.to_string(), wallet, ledger, 0) } fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> { diff --git a/src/app.rs b/src/app.rs @@ -93,11 +93,18 @@ pub struct NodeStatus { pub name: String, pub wallet_address: String, pub wallet_balance: Amount, + pub launch_profile: LaunchProfileStatus, pub mining: MiningStatus, pub chain: ChainStatus, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LaunchProfileStatus { + pub profile_id: String, + pub profile_hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct MiningStatus { pub automatic: bool, pub burn_per_block: Amount, @@ -294,6 +301,8 @@ impl NodeCore { } pub fn status(&self) -> NodeStatus { + let chain = self.ledger.status(); + let launch_profile = self.ledger.launch_profile(); let current_leader = self.ledger.expected_leader_for_next_block(); let wallet_is_current_leader = current_leader .as_deref() @@ -303,6 +312,10 @@ impl NodeCore { name: self.name.clone(), wallet_address: self.wallet.address().to_string(), wallet_balance: self.ledger.balance_of(self.wallet.address()), + launch_profile: LaunchProfileStatus { + profile_id: launch_profile.profile_id.clone(), + profile_hash: chain.launch_profile_hash.clone(), + }, mining: MiningStatus { automatic: true, burn_per_block: self.burn_per_block, @@ -312,7 +325,7 @@ impl NodeCore { wallet_is_current_leader, last_auto_burn_height: self.last_auto_burn_height, }, - chain: self.ledger.status(), + chain, } } @@ -431,9 +444,7 @@ impl NodeCore { } pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> { - let block = self - .ledger - .mine_next_block(self.wallet.address(), timestamp_ms)?; + let block = self.ledger.mine_next_block(&self.wallet, timestamp_ms)?; self.ledger.apply_locally_mined_block(block.clone())?; self.outbox.push(GossipEnvelope::Block(block.clone())); Ok(block) @@ -444,7 +455,7 @@ impl NodeCore { work: PreparedBlock, vdf_output: String, ) -> Result<Block> { - let block = work.finish(vdf_output); + let block = work.finish(&self.wallet, vdf_output); self.ledger.apply_locally_mined_block(block.clone())?; self.outbox.push(GossipEnvelope::Block(block.clone())); Ok(block) diff --git a/src/domain.rs b/src/domain.rs @@ -10,11 +10,11 @@ pub const BLOCK_REWARD: Amount = 100; pub const VDF_TARGET_BLOCK_MS: u64 = 60_000; const MAX_PENDING_TRANSACTIONS: usize = 10_000; const MAX_BLOCK_TRANSACTIONS: usize = 1_000; +const DEFAULT_TICKET_MATURITY_DELAY: u64 = 1; const MIN_VDF_ROUNDS: u32 = 1; const VDF_RETARGET_WINDOW_BLOCKS: usize = 10; const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 10; const FORK_FINALITY_DEPTH: u64 = 6; -const BETTER_VRF_MAX_SHORTER_BY: u64 = 2; const VDF_MODULUS: u128 = 4_611_685_975_477_714_963; const VDF_CHALLENGE_MIN: u64 = 1_073_741_827; @@ -64,6 +64,15 @@ impl Wallet { let signature: Signature = signing_key.sign(payload.as_bytes()); hex_encode(signature.to_bytes()) } + + fn leader_proof(&self, payload: &LeaderProofPayload) -> LeaderProof { + let signature = self.sign_payload(&payload.canonical()); + LeaderProof { + ticket_id: payload.ticket_id.clone(), + public_key: self.address.clone(), + signature, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -230,6 +239,7 @@ pub struct Block { pub reward: Amount, pub vdf_rounds: u32, pub vdf_output: String, + pub leader_proof: Option<LeaderProof>, pub transactions: Vec<Transaction>, pub hash: String, } @@ -244,6 +254,7 @@ impl Block { reward: draft.reward, vdf_rounds: draft.vdf_rounds, vdf_output: draft.vdf_output, + leader_proof: draft.leader_proof, transactions: draft.transactions, hash: String::new(), }; @@ -252,38 +263,107 @@ impl Block { } pub fn compute_hash(&self) -> String { - hex_hash(format!("block:{}:{}", self.vdf_seed(), self.vdf_output,)) + hex_hash(format!( + "block:{}:{}:{}", + self.content_hash(), + self.vdf_seed(), + self.vdf_output, + )) } pub fn vdf_seed(&self) -> String { - block_content_hash( + vdf_seed_for_child(&self.prev_hash, self.height) + } + + fn content_hash(&self) -> String { + let txs = self + .transactions + .iter() + .map(Transaction::canonical) + .collect::<Vec<_>>() + .join("|"); + let leader_proof = self + .leader_proof + .as_ref() + .map(|proof| { + format!( + "{}:{}:{}", + proof.ticket_id, proof.public_key, proof.signature + ) + }) + .unwrap_or_default(); + hex_hash(format!( + "block-content:{}:{}:{}:{}:{}:{}:{}:{}", self.height, - &self.prev_hash, + self.prev_hash, self.timestamp_ms, - &self.miner, + self.miner, self.reward, self.vdf_rounds, - &self.transactions, + leader_proof, + txs + )) + } + + fn leader_score(&self) -> LeaderScore { + LeaderScore( + self.leader_proof + .as_ref() + .map(LeaderProof::rank) + .unwrap_or_else(|| self.hash.clone()), ) } +} - pub fn burn_tickets(&self) -> Vec<(&str, Amount)> { - self.transactions - .iter() - .filter_map(|tx| match tx { - Transaction::Burn { from, amount, .. } if *amount > 0 => { - Some((from.as_str(), *amount)) - } - _ => None, - }) - .collect() +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LeaderProof { + pub ticket_id: String, + pub public_key: String, + pub signature: String, +} + +impl LeaderProof { + fn rank(&self) -> String { + hex_hash(format!( + "mivora-leader-rank:{}:{}", + self.ticket_id, self.signature + )) } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct LeaderProofPayload { + height: u64, + prev_hash: String, + vdf_output: String, + ticket_id: String, + ticket_amount: Amount, + ticket_owner: String, +} - fn leader_score(&self) -> LeaderScore<'_> { - LeaderScore(&self.hash) +impl LeaderProofPayload { + fn canonical(&self) -> String { + format!( + "mivora-leader-proof:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.vdf_output, + self.ticket_id, + self.ticket_amount, + self.ticket_owner + ) } } +#[derive(Clone, Debug, Eq, PartialEq)] +struct BurnTicket { + id: String, + owner: String, + amount: Amount, + target_height: u64, + maturity_height: u64, +} + #[derive(Clone, Debug)] pub struct PreparedBlock { height: u64, @@ -293,6 +373,7 @@ pub struct PreparedBlock { reward: Amount, vdf_rounds: u32, vdf_seed: String, + leader_ticket: BurnTicket, transactions: Vec<Transaction>, } @@ -309,7 +390,15 @@ impl PreparedBlock { self.height } - pub fn finish(self, vdf_output: String) -> Block { + pub fn finish(self, wallet: &Wallet, vdf_output: String) -> Block { + let proof_payload = LeaderProofPayload { + height: self.height, + prev_hash: self.prev_hash.clone(), + vdf_output: vdf_output.clone(), + ticket_id: self.leader_ticket.id.clone(), + ticket_amount: self.leader_ticket.amount, + ticket_owner: self.leader_ticket.owner.clone(), + }; Block::new(BlockDraft { height: self.height, prev_hash: self.prev_hash, @@ -318,6 +407,7 @@ impl PreparedBlock { reward: self.reward, vdf_rounds: self.vdf_rounds, vdf_output, + leader_proof: Some(wallet.leader_proof(&proof_payload)), transactions: self.transactions, }) } @@ -332,6 +422,7 @@ struct BlockDraft { reward: Amount, vdf_rounds: u32, vdf_output: String, + leader_proof: Option<LeaderProof>, transactions: Vec<Transaction>, } @@ -340,6 +431,7 @@ pub struct ChainStatus { pub height: u64, pub tip_hash: String, pub next_leader: Option<String>, + pub launch_profile_hash: String, pub block_reward: Amount, pub balances: BTreeMap<String, Amount>, pub pending_transactions: usize, @@ -349,9 +441,41 @@ pub struct ChainStatus { pub struct ChainSnapshot { pub genesis_allocations: BTreeMap<String, Amount>, pub vdf_rounds: u32, + pub launch_profile: LaunchProfile, pub blocks: Vec<Block>, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LaunchProfile { + pub profile_id: String, + pub ticket_maturity_delay_heights: u64, + pub max_pending_transactions: usize, + pub max_block_transactions: usize, +} + +impl Default for LaunchProfile { + fn default() -> Self { + Self { + profile_id: "mivora-devnet-v1".to_string(), + ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, + max_pending_transactions: MAX_PENDING_TRANSACTIONS, + max_block_transactions: MAX_BLOCK_TRANSACTIONS, + } + } +} + +impl LaunchProfile { + pub fn hash(&self) -> String { + hex_hash(format!( + "mivora-launch-profile:{}:{}:{}:{}", + self.profile_id, + self.ticket_maturity_delay_heights, + self.max_pending_transactions, + self.max_block_transactions + )) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct GenesisBurn { pub from: String, @@ -378,16 +502,16 @@ impl ForkPoint { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct LeaderScore<'a>(&'a str); +#[derive(Clone, Debug, Eq, PartialEq)] +struct LeaderScore(String); -impl Ord for LeaderScore<'_> { +impl Ord for LeaderScore { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.0.cmp(other.0) + self.0.cmp(&other.0) } } -impl PartialOrd for LeaderScore<'_> { +impl PartialOrd for LeaderScore { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { Some(self.cmp(other)) } @@ -422,10 +546,12 @@ pub struct Ledger { genesis_allocations: BTreeMap<String, Amount>, balances: BTreeMap<String, Amount>, nonces: BTreeMap<String, u64>, + tickets: Vec<BurnTicket>, pending: Vec<Transaction>, block_reward: Amount, initial_vdf_rounds: u32, vdf_rounds: u32, + launch_profile: LaunchProfile, } impl Ledger { @@ -451,17 +577,21 @@ impl Ledger { genesis_transactions: Vec<Transaction>, vdf_rounds: u32, ) -> Result<Self> { + let launch_profile = LaunchProfile::default(); let balances = balances_after_genesis(&genesis_allocations, &genesis_transactions)?; let genesis = build_genesis_block(&genesis_allocations, genesis_transactions); + let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?; Ok(Self { chain: vec![genesis], genesis_allocations: genesis_allocations.clone(), balances, nonces: BTreeMap::new(), + tickets, pending: Vec::new(), block_reward: BLOCK_REWARD, initial_vdf_rounds: vdf_rounds, vdf_rounds, + launch_profile, }) } @@ -473,6 +603,7 @@ impl Ledger { let ChainSnapshot { genesis_allocations, vdf_rounds, + launch_profile, blocks, } = snapshot; @@ -494,11 +625,18 @@ impl Ledger { genesis_allocations, balances, nonces: BTreeMap::new(), + tickets: Vec::new(), pending: Vec::new(), block_reward: BLOCK_REWARD, initial_vdf_rounds: vdf_rounds, vdf_rounds, + launch_profile, }; + ledger.tickets = genesis_tickets( + &ledger.genesis_allocations, + ledger.tip(), + &ledger.launch_profile, + )?; for block in blocks.into_iter().skip(1) { if verify_vdf { @@ -561,6 +699,9 @@ impl Ledger { if snapshot.vdf_rounds != self.initial_vdf_rounds { bail!("chain snapshot initial VDF rounds do not match local chain"); } + if snapshot.launch_profile != self.launch_profile { + bail!("chain snapshot launch profile does not match local chain"); + } if snapshot.genesis_allocations != self.genesis_allocations { bail!("chain snapshot genesis allocations do not match local chain"); } @@ -623,24 +764,16 @@ impl Ledger { return ForkChoice::KeepLocal; } - match self.fork_quality(candidate, fork_point) { - ForkQuality::RemoteBetter => { - if remote_height + BETTER_VRF_MAX_SHORTER_BY >= local_height { - return ForkChoice::SwitchToCandidate; - } - } - ForkQuality::LocalBetter => { - if local_height + BETTER_VRF_MAX_SHORTER_BY >= remote_height { - return ForkChoice::KeepLocal; - } - } - ForkQuality::Equal => {} + if remote_height > local_height { + return ForkChoice::SwitchToCandidate; + } + if remote_height < local_height { + return ForkChoice::KeepLocal; } - if remote_height > local_height { - ForkChoice::SwitchToCandidate - } else { - ForkChoice::KeepLocal + match self.fork_quality(candidate, fork_point) { + ForkQuality::RemoteBetter => ForkChoice::SwitchToCandidate, + ForkQuality::LocalBetter | ForkQuality::Equal => ForkChoice::KeepLocal, } } @@ -692,6 +825,7 @@ impl Ledger { ChainSnapshot { genesis_allocations: self.genesis_allocations.clone(), vdf_rounds: self.initial_vdf_rounds, + launch_profile: self.launch_profile.clone(), blocks: self.chain.clone(), } } @@ -701,6 +835,7 @@ impl Ledger { height: self.tip().height, tip_hash: self.tip().hash.clone(), next_leader: self.expected_leader_for_next_block(), + launch_profile_hash: self.launch_profile.hash(), block_reward: self.block_reward, balances: self.balances.clone(), pending_transactions: self.pending.len(), @@ -777,6 +912,10 @@ impl Ledger { self.vdf_rounds } + pub fn launch_profile(&self) -> &LaunchProfile { + &self.launch_profile + } + pub fn balance_of(&self, address: &str) -> Amount { self.balances.get(address).copied().unwrap_or(0) } @@ -842,41 +981,35 @@ impl Ledger { Ok(true) } - pub fn mine_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<Block> { - let prepared = self.prepare_next_block(miner, timestamp_ms)?; + pub fn mine_next_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<Block> { + let prepared = self.prepare_next_block(wallet.address(), timestamp_ms)?; let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - Ok(prepared.finish(vdf_output)) + Ok(prepared.finish(wallet, vdf_output)) } pub fn prepare_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { + let height = self.tip().height + 1; + let Some(leader_ticket) = self.selected_ticket_for_height(height) else { + bail!("cannot mine block without a mature burn ticket"); + }; if let Some(leader) = self.expected_leader_for_next_block() { if leader != miner { bail!("wallet {miner} is not the selected leader; expected {leader}"); } + } else { + bail!("no selected leader for block {height}"); } let transactions = self .valid_pending_transactions() .into_iter() - .take(MAX_BLOCK_TRANSACTIONS) + .take(self.launch_profile.max_block_transactions) .collect::<Vec<_>>(); - if !contains_positive_burn(&transactions) { - bail!("cannot mine block without burned coins"); - } let tip = self.tip(); let prev_hash = tip.hash.clone(); - let height = tip.height + 1; let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1); - let vdf_seed = block_content_hash( - height, - &prev_hash, - timestamp_ms, - miner, - self.block_reward, - self.vdf_rounds, - &transactions, - ); + let vdf_seed = vdf_seed_for_child(&prev_hash, height); Ok(PreparedBlock { height, prev_hash, @@ -885,6 +1018,7 @@ impl Ledger { reward: self.block_reward, vdf_rounds: self.vdf_rounds, vdf_seed, + leader_ticket, transactions, }) } @@ -924,7 +1058,10 @@ impl Ledger { } apply_transaction(tx, &mut balances, &mut nonces)?; } + let mut tickets = self.tickets.clone(); + consume_leader_ticket(&block, &mut tickets)?; credit_balance(&mut balances, &block.miner, block.reward)?; + tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); let mined_signatures = block .transactions @@ -933,6 +1070,7 @@ impl Ledger { .collect::<BTreeSet<_>>(); self.balances = balances; self.nonces = nonces; + self.tickets = tickets; self.pending.retain(|tx| { !mined_signatures.contains(tx.signature()) && tx.nonce() > self.nonces.get(tx.sender()).copied().unwrap_or(0) @@ -979,20 +1117,29 @@ impl Ledger { if block.timestamp_ms <= self.tip().timestamp_ms { bail!("block timestamp must increase"); } - if block.transactions.len() > MAX_BLOCK_TRANSACTIONS { + if block.transactions.len() > self.launch_profile.max_block_transactions { bail!("block has too many transactions"); } - if !contains_positive_burn(&block.transactions) { - bail!("block must include burned coins"); + let Some(leader) = self.expected_leader_for_next_block() else { + bail!("no selected leader for block {}", block.height); + }; + if leader != block.miner { + bail!( + "block miner {} is not selected leader {leader}", + block.miner + ); } - if let Some(leader) = self.expected_leader_for_next_block() { - if leader != block.miner { - bail!( - "block miner {} is not selected leader {leader}", - block.miner - ); - } + let selected_ticket = self + .selected_ticket_for_height(block.height) + .context("no selected ticket for leader block")?; + if block + .leader_proof + .as_ref() + .is_none_or(|proof| proof.ticket_id != selected_ticket.id) + { + bail!("block does not prove the selected leader ticket"); } + verify_leader_proof(block, &self.tickets)?; Ok(true) } @@ -1020,25 +1167,8 @@ impl Ledger { } pub fn expected_leader_for_next_block(&self) -> Option<String> { - let tip = self.tip(); - let tickets = tip.burn_tickets(); - if tickets.is_empty() { - return None; - } - let total_burned = tickets - .iter() - .map(|(_, amount)| u128::from(*amount)) - .sum::<u128>(); - let seed = hash_to_u64(format!("leader:{}:{}", tip.hash, tip.vdf_output)); - let winning_ticket = u128::from(seed) % total_burned; - let mut cumulative = 0_u128; - for (address, amount) in tickets { - cumulative += u128::from(amount); - if winning_ticket < cumulative { - return Some(address.to_string()); - } - } - None + self.selected_ticket_for_height(self.tip().height + 1) + .map(|ticket| ticket.owner) } fn valid_pending_transactions(&self) -> Vec<Transaction> { @@ -1070,6 +1200,18 @@ impl Ledger { valid } + fn selected_ticket_for_height(&self, height: u64) -> Option<BurnTicket> { + self.tickets + .iter() + .filter(|ticket| ticket.target_height == height && ticket.maturity_height <= height) + .min_by(|left, right| { + ticket_rank(self.tip(), height, left) + .cmp(&ticket_rank(self.tip(), height, right)) + .then_with(|| left.id.cmp(&right.id)) + }) + .cloned() + } + fn tip(&self) -> &Block { self.chain .last() @@ -1077,29 +1219,128 @@ impl Ledger { } } -fn contains_positive_burn(transactions: &[Transaction]) -> bool { - transactions +fn ticket_rank(parent: &Block, target_height: u64, ticket: &BurnTicket) -> String { + hex_hash(format!( + "mivora-ticket-rank:{}:{}:{}:{}:{}", + target_height, parent.hash, parent.vdf_output, ticket.id, ticket.amount + )) +} + +fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Vec<BurnTicket>> { + let mut tickets = Vec::new(); + for tx in &block.transactions { + let Transaction::Burn { + from, + amount, + signature, + .. + } = tx + else { + continue; + }; + if *amount == 0 { + continue; + } + let target_height = block + .height + .checked_add(profile.ticket_maturity_delay_heights) + .with_context(|| format!("ticket target height overflow at block {}", block.height))?; + let maturity_height = target_height; + tickets.push(BurnTicket { + id: signature.clone(), + owner: from.clone(), + amount: *amount, + target_height, + maturity_height, + }); + } + Ok(tickets) +} + +fn genesis_tickets( + genesis_allocations: &BTreeMap<String, Amount>, + genesis: &Block, + profile: &LaunchProfile, +) -> Result<Vec<BurnTicket>> { + let tickets = tickets_created_by_block(genesis, profile)?; + if !tickets.is_empty() { + return Ok(tickets); + } + + let Some((owner, amount)) = genesis_allocations.iter().find(|(_, amount)| **amount > 0) else { + return Ok(Vec::new()); + }; + Ok(vec![BurnTicket { + id: hex_hash(format!( + "mivora-genesis-ticket:{owner}:{amount}:{}", + genesis.hash + )), + owner: owner.clone(), + amount: 1, + target_height: profile.ticket_maturity_delay_heights, + maturity_height: profile.ticket_maturity_delay_heights, + }]) +} + +fn consume_leader_ticket(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result<()> { + let Some(proof) = &block.leader_proof else { + bail!("block is missing leader proof"); + }; + let Some(index) = tickets .iter() - .any(|tx| matches!(tx, Transaction::Burn { amount, .. } if *amount > 0)) + .position(|ticket| ticket.id == proof.ticket_id && ticket.target_height == block.height) + else { + bail!("leader ticket is not pending for block {}", block.height); + }; + tickets.remove(index); + Ok(()) } -fn block_content_hash( - height: u64, - prev_hash: &str, - timestamp_ms: u64, - miner: &str, - reward: Amount, - vdf_rounds: u32, - transactions: &[Transaction], -) -> String { - let txs = transactions +fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> { + let Some(proof) = &block.leader_proof else { + bail!("block is missing leader proof"); + }; + if proof.public_key != block.miner { + bail!("leader proof public key does not match block miner"); + } + let ticket = tickets .iter() - .map(Transaction::canonical) - .collect::<Vec<_>>() - .join("|"); - hex_hash(format!( - "block-content:{height}:{prev_hash}:{timestamp_ms}:{miner}:{reward}:{vdf_rounds}:{txs}" - )) + .find(|ticket| ticket.id == proof.ticket_id && ticket.target_height == block.height) + .context("leader ticket is not pending for this height")?; + if ticket.owner != block.miner { + bail!("leader ticket owner does not match block miner"); + } + if ticket.maturity_height > block.height { + bail!("leader ticket is not mature"); + } + + let payload = LeaderProofPayload { + height: block.height, + prev_hash: block.prev_hash.clone(), + vdf_output: block.vdf_output.clone(), + ticket_id: ticket.id.clone(), + ticket_amount: ticket.amount, + ticket_owner: ticket.owner.clone(), + }; + verify_leader_signature(proof, &payload)?; + Ok(()) +} + +fn verify_leader_signature(proof: &LeaderProof, payload: &LeaderProofPayload) -> Result<()> { + let public_key = decode_hex_array::<32>(&proof.public_key) + .with_context(|| format!("invalid leader public key {}", proof.public_key))?; + let signature = + decode_hex_array::<64>(&proof.signature).context("invalid leader signature hex")?; + let verifying_key = + VerifyingKey::from_bytes(&public_key).context("invalid leader public key")?; + let signature = Signature::from_bytes(&signature); + verifying_key + .verify(payload.canonical().as_bytes(), &signature) + .context("leader signature is invalid") +} + +fn vdf_seed_for_child(prev_hash: &str, height: u64) -> String { + hex_hash(format!("mivora-vdf-child:{prev_hash}:{height}")) } fn apply_transaction( @@ -1180,6 +1421,7 @@ fn build_genesis_block( reward: 0, vdf_rounds: 0, vdf_output, + leader_proof: None, transactions, hash: String::new(), }; @@ -1206,6 +1448,9 @@ fn validate_genesis_block(block: &Block) -> Result<()> { if block.vdf_rounds != 0 { bail!("genesis block VDF rounds must be 0"); } + if block.leader_proof.is_some() { + bail!("genesis block must not carry a leader proof"); + } if block.compute_hash() != block.hash { bail!("genesis block hash is invalid"); } @@ -1403,16 +1648,6 @@ fn hex_value(byte: u8) -> Result<u8> { } } -fn hash_to_u64(input: impl AsRef<[u8]>) -> u64 { - let digest = Sha256::digest(input.as_ref()); - u64::from_be_bytes( - digest[..8] - .try_into() - .map_err(|_| anyhow!("sha256 digest had unexpected length")) - .expect("sha256 digest is at least eight bytes"), - ) -} - fn hex_encode(bytes: impl AsRef<[u8]>) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let bytes = bytes.as_ref(); diff --git a/src/main.rs b/src/main.rs @@ -172,7 +172,7 @@ impl CliOptions { .parse() .context("invalid --genesis-amount")?; if opts.genesis_amount < 1 { - bail!("--genesis-amount must be at least 1 for the genesis burn"); + bail!("--genesis-amount must be at least 1 for the genesis ticket"); } } "--vdf-rounds" => { @@ -237,7 +237,7 @@ fn print_help() { --p2p <addr:port> P2P TCP listener address (default 127.0.0.1:9444)\n\ --peer <addr:port> P2P peer to gossip to; may be repeated\n\ --join <addr:port> Fetch chain snapshot from this peer before mining\n\ - --genesis-amount <amount> Pre-burn genesis amount for this wallet (default 1)\n\ + --genesis-amount <amount> Genesis allocation before the 1-coin bootstrap ticket (default 1)\n\ --vdf-rounds <rounds> Initial VDF delay rounds; protocol retargets toward 60s blocks\n\ --burn-per-block <amount> Fixed automatic burn before each block attempt\n\ --data-dir <path> Local wallet directory\n" @@ -443,7 +443,7 @@ mod tests { ledger .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) .unwrap(); - let block = ledger.mine_next_block(wallet.address(), 1_000).unwrap(); + let block = ledger.mine_next_block(wallet, 1_000).unwrap(); ledger.apply_locally_mined_block(block).unwrap(); ledger } diff --git a/tests/coin.rs b/tests/coin.rs @@ -34,9 +34,7 @@ fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u6 ledger .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) .unwrap(); - let block = ledger - .mine_next_block(wallet.address(), timestamp_ms) - .unwrap(); + let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap(); let hash = block.hash.clone(); ledger.apply_block(block).unwrap(); hash @@ -101,25 +99,20 @@ fn genesis_burn_starts_chain_with_zero_balance_and_first_leader() { } #[test] -fn starter_node_waits_for_burn_before_first_reward() { +fn starter_node_mines_first_reward_from_genesis_ticket() { let alice = Wallet::from_seed("alice"); let mut node = starter_node("alice", alice.clone()); let outcome = node.automatic_mine_once(1); assert!(outcome.burned.is_none()); - assert!(outcome.block.is_none()); - assert!( - outcome - .skipped_reason - .unwrap() - .contains("without burned coins") - ); - assert_eq!(node.ledger().status().height, 0); - assert_eq!(node.ledger().balance_of(alice.address()), 0); + assert_eq!(outcome.block.as_ref().map(|block| block.height), Some(1)); + assert!(outcome.skipped_reason.is_none()); + assert_eq!(node.ledger().status().height, 1); + assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD); } #[test] -fn burn_in_latest_block_selects_next_leader() { +fn burn_in_latest_block_creates_next_height_ticket() { let alice = Wallet::from_seed("alice"); let bob = Wallet::from_seed("bob"); let mut allocations = BTreeMap::new(); @@ -134,16 +127,16 @@ fn burn_in_latest_block_selects_next_leader() { .submit_transaction(bob.burn(80, ledger.next_nonce(bob.address()))) .unwrap(); - let first = ledger.mine_next_block(alice.address(), 1).unwrap(); + let first = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(first).unwrap(); let expected = ledger.expected_leader_for_next_block().unwrap(); assert!(expected == alice.address() || expected == bob.address()); let non_leader = if expected == alice.address() { - bob.address() + &bob } else { - alice.address() + &alice }; assert!(ledger.mine_next_block(non_leader, 2).is_err()); @@ -155,7 +148,7 @@ fn burn_in_latest_block_selects_next_leader() { ledger .submit_transaction(leader_wallet.burn(1, ledger.next_nonce(leader_wallet.address()))) .unwrap(); - assert!(ledger.mine_next_block(&expected, 2).is_ok()); + assert!(ledger.mine_next_block(leader_wallet, 2).is_ok()); } #[test] @@ -173,7 +166,7 @@ fn transfer_and_burn_update_balances_when_block_is_applied() { ledger .submit_transaction(alice.burn(25, ledger.next_nonce(alice.address()))) .unwrap(); - let block = ledger.mine_next_block(alice.address(), 1).unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(block).unwrap(); assert_eq!(ledger.balance_of(alice.address()), 950); @@ -208,7 +201,7 @@ fn block_with_forged_transaction_is_rejected() { .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) .unwrap(); - let mut block = ledger.mine_next_block(alice.address(), 1).unwrap(); + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); if let mivora::domain::Transaction::Burn { signature, .. } = &mut block.transactions[0] { signature.push_str("00"); } @@ -229,7 +222,7 @@ fn block_reward_is_fixed_at_one_hundred_coins() { ledger .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) .unwrap(); - let block = ledger.mine_next_block(alice.address(), 1).unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); assert_eq!(block.reward, BLOCK_REWARD); ledger.apply_block(block).unwrap(); @@ -262,7 +255,7 @@ fn block_reward_that_would_overflow_miner_balance_is_rejected() { ledger .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) .unwrap(); - let block = ledger.mine_next_block(alice.address(), 1).unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); let error = ledger.apply_block(block).unwrap_err(); @@ -270,29 +263,33 @@ fn block_reward_that_would_overflow_miner_balance_is_rejected() { } #[test] -fn block_without_burned_coins_cannot_be_mined_or_applied() { +fn block_without_mature_ticket_cannot_be_mined() { + let alice = Wallet::from_seed("alice"); + let allocations = BTreeMap::new(); + + let ledger = Ledger::new(allocations, 10); + let error = ledger.mine_next_block(&alice, 1).unwrap_err(); + assert!(error.to_string().contains("mature burn ticket")); +} + +#[test] +fn leader_block_can_create_no_future_tickets() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new(); allocations.insert(alice.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations, 10); - let error = ledger.mine_next_block(alice.address(), 1).unwrap_err(); - assert!(error.to_string().contains("without burned coins")); - - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let mut block = ledger.mine_next_block(alice.address(), 1).unwrap(); + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); block.transactions.clear(); block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds); block.hash = block.compute_hash(); - let error = ledger.apply_block(block).unwrap_err(); - assert!(error.to_string().contains("must include burned coins")); + ledger.apply_block(block).unwrap(); + assert_eq!(ledger.status().height, 1); } #[test] -fn vdf_is_bound_to_block_contents() { +fn block_hash_is_bound_to_block_contents() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new(); allocations.insert(alice.address().to_string(), 1_000); @@ -301,12 +298,11 @@ fn vdf_is_bound_to_block_contents() { ledger .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) .unwrap(); - let mut block = ledger.mine_next_block(alice.address(), 1).unwrap(); + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); block.timestamp_ms += 1; - block.hash = block.compute_hash(); let error = ledger.apply_block(block).unwrap_err(); - assert!(error.to_string().contains("VDF output is invalid")); + assert!(error.to_string().contains("block hash is invalid")); } #[test] @@ -345,14 +341,9 @@ fn default_automatic_mining_does_not_burn() { let outcome = node.automatic_mine_once(1); assert!(outcome.burned.is_none()); - assert!(outcome.block.is_none()); - assert!( - outcome - .skipped_reason - .unwrap() - .contains("without burned coins") - ); - assert_eq!(node.ledger().balance_of(alice.address()), 1_000); + assert_eq!(outcome.block.as_ref().map(|block| block.height), Some(1)); + assert!(outcome.skipped_reason.is_none()); + assert_eq!(node.ledger().balance_of(alice.address()), 1_100); } #[test] @@ -382,7 +373,7 @@ fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() { ledger .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) .unwrap(); - let first = ledger.mine_next_block(alice.address(), 1).unwrap(); + let first = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(first).unwrap(); let mut bob_node = node("bob", bob.clone(), allocations); @@ -416,7 +407,7 @@ fn automatic_mining_waits_when_wallet_is_not_selected_leader() { ledger .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) .unwrap(); - let first = ledger.mine_next_block(alice.address(), 1).unwrap(); + let first = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(first).unwrap(); let mut bob_node = node("bob", bob.clone(), allocations); @@ -439,12 +430,14 @@ fn block_with_wrong_vdf_rounds_is_rejected() { let wallet = Wallet::from_seed("alice"); let mut genesis = BTreeMap::new(); genesis.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new(genesis, 25); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25) + .unwrap(); ledger .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) .unwrap(); - let mut block = ledger.mine_next_block(wallet.address(), 1).unwrap(); + let mut block = ledger.mine_next_block(&wallet, 1).unwrap(); block.vdf_rounds = 1; block.hash = block.compute_hash(); block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds); @@ -473,7 +466,7 @@ fn vdf_rounds_retarget_toward_one_minute_blocks() { .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) .unwrap(); let block1 = ledger - .mine_next_block(wallet.address(), VDF_TARGET_BLOCK_MS) + .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS) .unwrap(); assert_eq!(block1.vdf_rounds, 100); ledger.apply_block(block1).unwrap(); @@ -483,10 +476,7 @@ fn vdf_rounds_retarget_toward_one_minute_blocks() { .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) .unwrap(); let block2 = ledger - .mine_next_block( - wallet.address(), - VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2, - ) + .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2) .unwrap(); assert_eq!(block2.vdf_rounds, 100); ledger.apply_block(block2).unwrap(); @@ -497,7 +487,7 @@ fn vdf_rounds_retarget_toward_one_minute_blocks() { .unwrap(); let block3 = ledger .mine_next_block( - wallet.address(), + &wallet, VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2 + VDF_TARGET_BLOCK_MS * 2, ) .unwrap(); @@ -526,7 +516,7 @@ fn future_nonce_transactions_wait_for_missing_gap() { let block = ledger .prepare_next_block(wallet.address(), 1) .unwrap() - .finish("test-vdf".to_string()); + .finish(&wallet, "test-vdf".to_string()); assert_eq!(block.transactions.len(), 3); assert!(block.transactions.contains(&tx3)); } @@ -1074,7 +1064,7 @@ fn mempool_gossip_repairs_future_nonce_gap_without_networking() { other => bob_node.receive(other).unwrap(), } } - let block = bob_node.mine_one_at(1).unwrap(); + let block = alice_node.mine_one_at(1).unwrap(); let signatures = block .transactions .iter() @@ -1259,7 +1249,7 @@ fn chain_snapshot_round_trips_ledger_state() { ledger .submit_transaction(alice.burn(10, ledger.next_nonce(alice.address()))) .unwrap(); - let block = ledger.mine_next_block(alice.address(), 1).unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(block).unwrap(); let restored = Ledger::from_snapshot(ledger.snapshot()).unwrap(); @@ -1327,11 +1317,16 @@ fn same_height_fork_snapshot_does_not_reorg() { let bob = Wallet::from_seed("bob"); let wallets = vec![alice.clone(), bob.clone()]; let shared_genesis = allocations(&wallets, 1_000); - let base = Ledger::new(shared_genesis, 1); + let base = Ledger::new_with_genesis_burns( + shared_genesis, + vec![GenesisBurn::new(alice.address(), 1)], + 1, + ) + .unwrap(); let mut local = base.clone(); let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 1); - let remote = fork_with_worse_vrf_block(&base, &bob, &local_first_fork_hash, 1).unwrap(); + let remote = fork_with_worse_vrf_block(&base, &alice, &local_first_fork_hash, 1).unwrap(); let local_tip = local.status().tip_hash; assert!(!local.extend_from_snapshot(remote.snapshot()).unwrap()); @@ -1423,7 +1418,7 @@ fn fork_conflict_before_last_six_blocks_is_finalized_even_if_remote_is_longer() } #[test] -fn better_vrf_fork_inside_last_six_wins_when_no_more_than_two_blocks_shorter() { +fn shorter_better_rank_fork_inside_last_six_does_not_beat_positive_quality() { let alice = Wallet::from_seed("better-vrf-alice"); let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); let mut common = Ledger::new(shared_genesis, 1); @@ -1448,11 +1443,11 @@ fn better_vrf_fork_inside_last_six_wins_when_no_more_than_two_blocks_shorter() { let remote_tip = remote.status().tip_hash; assert!( - local.extend_from_snapshot(remote.snapshot()).unwrap(), - "a better VRF fork inside the last six should win while at most two blocks shorter" + !local.extend_from_snapshot(remote.snapshot()).unwrap(), + "a shorter fork should not beat greater positive chain quality" ); - assert_eq!(local.status().height, 6); - assert_eq!(local.status().tip_hash, remote_tip); + assert_eq!(local.status().height, 8); + assert_ne!(local.status().tip_hash, remote_tip); } #[test] @@ -1492,7 +1487,12 @@ fn transactions_from_abandoned_fork_blocks_return_to_mempool_after_switch() { let carol = Wallet::from_seed("reorg-carol"); let wallets = vec![alice.clone(), bob.clone(), carol.clone()]; let shared_genesis = allocations(&wallets, 10_000); - let mut common = Ledger::new(shared_genesis, 1); + let mut common = Ledger::new_with_genesis_burns( + shared_genesis, + vec![GenesisBurn::new(alice.address(), 1)], + 1, + ) + .unwrap(); mine_wallet_burn_block(&mut common, &alice, 1); let mut local = common.clone(); @@ -1523,25 +1523,35 @@ fn longer_valid_fork_snapshot_reorgs_and_preserves_local_transactions() { let bob = Wallet::from_seed("bob"); let wallets = vec![alice.clone(), bob.clone()]; let shared_genesis = allocations(&wallets, 1_000); - let mut local = Ledger::new(shared_genesis.clone(), 1); - let mut remote = Ledger::new(shared_genesis, 1); + let mut local = Ledger::new_with_genesis_burns( + shared_genesis.clone(), + vec![GenesisBurn::new(alice.address(), 1)], + 1, + ) + .unwrap(); + let mut remote = Ledger::new_with_genesis_burns( + shared_genesis, + vec![GenesisBurn::new(alice.address(), 1)], + 1, + ) + .unwrap(); let local_burn = alice.burn(1, local.next_nonce(alice.address())); local.submit_transaction(local_burn.clone()).unwrap(); - let local_block = local.mine_next_block(alice.address(), 1).unwrap(); + let local_block = local.mine_next_block(&alice, 1).unwrap(); local.apply_block(local_block).unwrap(); - let local_transfer = alice.transfer(bob.address(), 5, local.next_nonce(alice.address())); + let local_transfer = bob.transfer(alice.address(), 5, local.next_nonce(bob.address())); local.submit_transaction(local_transfer.clone()).unwrap(); remote - .submit_transaction(bob.burn(1, remote.next_nonce(bob.address()))) + .submit_transaction(alice.burn(1, remote.next_nonce(alice.address()))) .unwrap(); - let remote_block_1 = remote.mine_next_block(bob.address(), 1).unwrap(); + let remote_block_1 = remote.mine_next_block(&alice, 1).unwrap(); remote.apply_block(remote_block_1).unwrap(); remote - .submit_transaction(bob.burn(1, remote.next_nonce(bob.address()))) + .submit_transaction(alice.burn(1, remote.next_nonce(alice.address()))) .unwrap(); - let remote_block_2 = remote.mine_next_block(bob.address(), 2).unwrap(); + let remote_block_2 = remote.mine_next_block(&alice, 2).unwrap(); remote.apply_block(remote_block_2).unwrap(); let remote_tip = remote.status().tip_hash; @@ -1552,12 +1562,6 @@ fn longer_valid_fork_snapshot_reorgs_and_preserves_local_transactions() { local .pending() .iter() - .any(|tx| tx.signature() == local_burn.signature()) - ); - assert!( - local - .pending() - .iter() .any(|tx| tx.signature() == local_transfer.signature()) ); }