iuna

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

commit 5f48038ec30d6c6d446cd54d324870b1acd3516b
parent e877771733cb32c0fa64380efa35f094ff58eb6e
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 22 Jul 2026 16:49:49 +0200

Rename project to Luun

Diffstat:
MCargo.lock | 32++++++++++++++++----------------
MCargo.toml | 2+-
MREADME.md | 30+++++++++++++++---------------
Aassets/luun-ui.js | 591+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dassets/mivora-ui.js | 591-------------------------------------------------------------------------------
Mdevlogs/001-node-first.md | 10+++++-----
Mdevlogs/002-vdf-clock.md | 4++--
Mdevlogs/003-friend-join.md | 6+++---
Mdevlogs/004-wallet-file.md | 4++--
Mdevlogs/005-burned-blocks-and-vdf.md | 2+-
Mdevlogs/008-persistent-peer-sessions.md | 2+-
Mdevlogs/009-longer-fork-reorgs.md | 2+-
Mdevlogs/010-hello-and-inventory.md | 2+-
Msrc/adapters/http.rs | 34+++++++++++++++++-----------------
Msrc/app.rs | 2+-
Msrc/domain.rs | 31++++++++++++++++++-------------
Msrc/main.rs | 47+++++++++++++++++++++++------------------------
Dtests/coin.rs | 1732-------------------------------------------------------------------------------
Atests/luun.rs | 1725+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 2423 insertions(+), 2426 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -474,6 +474,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] +name = "luun" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "ed25519-dalek", + "getrandom 0.2.17", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", +] + +[[package]] name = "matchit" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -503,22 +519,6 @@ dependencies = [ ] [[package]] -name = "mivora" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "ed25519-dalek", - "getrandom 0.2.17", - "rusqlite", - "serde", - "serde_json", - "sha2", - "tempfile", - "tokio", -] - -[[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "mivora" +name = "luun" version = "0.1.0" edition = "2024" diff --git a/README.md b/README.md @@ -1,6 +1,6 @@ -# Mivora +# Luun -Mivora is a tiny L1 coin prototype built from the node first, then explained as it grows. +Luun is a tiny L1 prototype built from the node first, then explained as it grows. The current devnet assumes friendly nodes. It has one binary that acts as wallet, node, miner, HTTP management UI, and P2P TCP listener. The core ledger is separated from the adapters so a whole network can be tested in memory without opening sockets. @@ -10,9 +10,9 @@ The current devnet assumes friendly nodes. It has one binary that acts as wallet cargo run -- --http 127.0.0.1:18661 --p2p 127.0.0.1:9444 ``` -Open `http://127.0.0.1:18661` and complete the initial setup modal. The setup flow lets you generate a local recovery phrase or import one, verifies generated phrases with a 4-word check, stores the wallet in `.mivora/wallet.json`, stores runtime config in `.mivora/config.json`, and does not create a chain yet. +Open `http://127.0.0.1:18661` and complete the initial setup modal. The setup flow lets you generate a local recovery phrase or import one, verifies generated phrases with a 4-word check, stores the wallet in `.luun/wallet.json`, stores runtime config in `.luun/config.json`, and does not create a chain yet. -For fast local development, set `MIVORA_DEV_SKIP_SEED_VERIFY=1` before starting the node to show a setup-only skip button for the recovery phrase check. +For fast local development, set `LUUN_DEV_SKIP_SEED_VERIFY=1` before starting the node to show a setup-only skip button for the recovery phrase check. After setup, restart with genesis mode: @@ -20,11 +20,11 @@ After setup, restart with genesis mode: cargo run -- --genesis --http 127.0.0.1:18661 --p2p 127.0.0.1:9444 ``` -`--genesis` only works when the wallet file already exists and `.mivora/chain.sqlite3` does not already contain a blockchain. It creates the starter chain, adaptively measures VDF throughput locally, extrapolates that measurement to a 60-second initial round count, and persists the validated chain. The same data directory resumes automatically on later runs without `--genesis`. +`--genesis` only works when the wallet file already exists and `.luun/chain.sqlite3` does not already contain a blockchain. It creates the starter chain, adaptively measures VDF throughput locally, extrapolates that measurement to a 60-second initial round count, and persists the validated chain. The same data directory resumes automatically on later runs without `--genesis`. 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. -Genesis leaves the starter wallet with 100 spendable coins after the 1-coin bootstrap burn creates the first leader ticket and the genesis block pays its 100-coin reward. `--genesis` starts the automatic burn rate at 100 coins per block, so the starter can immediately create the block 1 burn ticket. +Genesis leaves the starter wallet with 100 spendable LUUN after the 1-LUUN bootstrap burn creates the first leader ticket and the genesis block pays its 100-LUUN reward. `--genesis` starts the automatic burn rate at 100 LUUN per block, so the starter can immediately create the block 1 burn ticket. The management UI is a small AlpineJS app served from local vendored assets. It polls JSON endpoints every few seconds and includes: @@ -37,7 +37,7 @@ The management UI is a small AlpineJS app served from local vendored assets. It For a second local node joining Alice's chain: ```sh -cargo run -- --data-dir .mivora-bob --http 127.0.0.1:18662 --p2p 127.0.0.1:9445 --join 127.0.0.1:9444 +cargo run -- --data-dir .luun-bob --http 127.0.0.1:18662 --p2p 127.0.0.1:9445 --join 127.0.0.1:9444 ``` `--join` fetches a chain snapshot from the peer before mining starts and announces this node's P2P listener back to that peer, so newly mined blocks can flow back without restarting the first node. If the peer cannot provide a snapshot, the node exits instead of silently starting a separate chain. Additional peers can be added from the P2P screen. @@ -68,38 +68,38 @@ cargo run -- --genesis --p2p 0.0.0.0:9444 --http 127.0.0.1:18661 4. Friends join your chain: ```sh -cargo run -- --data-dir .mivora-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host:9444 +cargo run -- --data-dir .luun-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host:9444 ``` -Friends who join after you start will adopt your genesis and current chain. The starter wallet begins with 100 spendable coins after the bootstrap burn and genesis reward, and `--genesis` starts it with a 100-coin automatic burn rate. After the starter mines additional block rewards, 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. The starter wallet begins with 100 spendable LUUN after the bootstrap burn and genesis reward, and `--genesis` starts it with a 100-LUUN automatic burn rate. After the starter mines additional block rewards, send friends LUUN from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-LUUN 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 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. +The genesis block bootstraps the chain with a 1-LUUN 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 consume the selected mature ticket, include at least one burn transaction, and fit under the 100kB serialized block limit. The VDF seed is bound to the parent hash and child height; the block hash separately commits to the miner, timestamp, miner payout, 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. -The base block reward is fixed at 100 coins, and miners collect transaction fees on top. The miner includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. The default burn is 0 coins per block, so new nodes can join before they own coins. Genesis starters begin at 100 coins per block; after another wallet has coins, raise its burn from the Mining screen. +The base block reward is fixed at 100 LUUN, and miners collect transaction fees on top. The miner includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. The default burn is 0 LUUN per block, so new nodes can join before they own LUUN. Genesis starters begin at 100 LUUN per block; after another wallet has LUUN, raise its burn from the Mining screen. The measured VDF round count is only the initial delay. After the first blocks, the protocol steers rounds toward the 60-second target. ## Wallet Storage -Mivora creates a new wallet file the first time a node starts or joins a chain. By default it lives at `.mivora/wallet.json`, or at `<data-dir>/wallet.json` when `--data-dir` is set. Pass `--wallet path/to/wallet.json` to choose a specific wallet file. New wallet files store a 24-word recovery phrase and the derived Ed25519 public key address. +Luun creates a new wallet file the first time a node starts or joins a chain. By default it lives at `.luun/wallet.json`, or at `<data-dir>/wallet.json` when `--data-dir` is set. Pass `--wallet path/to/wallet.json` to choose a specific wallet file. New wallet files store a 24-word recovery phrase and the derived Ed25519 public key address. There is no default wallet seed in the binary. Keep the wallet file private; it contains the local wallet seed used to derive the address. ## Node Config -Mivora stores UI setup state, configured peers, and the configured automatic burn rate in `<data-dir>/config.json`. If `setup_complete` is false, the management UI opens the initial setup screen for wallet and peer setup. Completing setup and later runtime changes write the file through the HTTP API, so the choices follow the node data directory instead of a browser session. +Luun stores UI setup state, configured peers, and the configured automatic burn rate in `<data-dir>/config.json`. If `setup_complete` is false, the management UI opens the initial setup screen for wallet and peer setup. Completing setup and later runtime changes write the file through the HTTP API, so the choices follow the node data directory instead of a browser session. ## Chain Storage -Mivora stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chain.sqlite3`. The database is updated by a small background persistence task when the tip changes, so web requests, P2P sessions, and VDF work do not perform chain database writes on their main async paths. +Luun stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chain.sqlite3`. The database is updated by a small background persistence task when the tip changes, so web requests, P2P sessions, and VDF work do not perform chain database writes on their main async paths. ## Architecture -- `src/domain.rs`: wallet, fee-paying transactions, balances, genesis burn bootstrap, 100-coin base rewards, 100kB blocks, mature leader tickets, leader proofs, fork choice, launch profile, and VDF checks. +- `src/domain.rs`: wallet, fee-paying transactions, balances, genesis burn bootstrap, 100-LUUN base rewards, 100kB 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/luun-ui.js b/assets/luun-ui.js @@ -0,0 +1,591 @@ +window.luunApp = function luunApp() { + return { + tab: "wallet", + status: {}, + blocks: [], + selectedBlock: null, + loadingOlder: false, + hasMoreBlocks: true, + mempool: [], + peers: [], + config: { setup_complete: false }, + setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false }, + setupWalletMode: "create", + setupSeedStep: "write", + generatedSeedPhrase: "", + verifyChallenges: [], + verifyAnswers: {}, + importSeedPhrase: "", + walletVerified: false, + setupFeedback: null, + burnAmount: 0, + burnAmountDraft: 0, + burnAmountDirty: false, + transferTo: "", + transferAmount: null, + transferFee: 1, + peerAddress: "", + showBurnTransactions: false, + flash: null, + flashTimer: null, + lastUpdated: null, + pollHandle: null, + newBlockHashes: new Set(), + newBlockTimer: null, + blockPageSize: 20, + + init() { + this.bootstrap(); + }, + + async bootstrap() { + await this.refreshConfig(); + if (!this.config.setup_complete) { + await this.refreshWalletSetup(); + } + this.tab = this.tabFromHash(); + window.addEventListener("hashchange", () => { + this.tab = this.tabFromHash(); + }); + await 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; + } + }, + + pageTitle() { + return { + wallet: "Luun", + mining: "Mining", + p2p: "P2P", + chain: "Chain", + }[this.tab] || "Luun"; + }, + + showingSetup() { + return !this.config.setup_complete; + }, + + async refreshConfig() { + this.config = await this.fetchJson("/api/config"); + }, + + async refreshWalletSetup() { + const payload = await this.fetchJson("/api/wallet/setup"); + if (!payload.ok) { + throw new Error(payload.error || "Could not load wallet setup"); + } + this.setupWallet = payload; + if ( + payload.seed_phrase && + payload.seed_phrase !== this.generatedSeedPhrase && + this.setupWalletMode === "create" && + !this.walletVerified + ) { + this.generatedSeedPhrase = payload.seed_phrase; + this.walletVerified = false; + this.setupSeedStep = "write"; + this.verifyChallenges = []; + this.verifyAnswers = {}; + } + }, + + setupSeedWords() { + return this.generatedSeedPhrase ? this.generatedSeedPhrase.split(/\s+/) : []; + }, + + setupAddress() { + return this.setupWallet.address || this.status.wallet_address || "-"; + }, + + selectSetupWalletMode(mode) { + this.setupWalletMode = mode; + this.walletVerified = mode === "import" ? this.walletVerified && !this.generatedSeedPhrase : false; + this.setupFeedback = null; + }, + + async generateSetupSeed() { + try { + this.setupFeedback = null; + const payload = await this.postWalletSetup("/api/wallet/generate", {}); + this.setupWallet = payload; + this.generatedSeedPhrase = payload.seed_phrase || ""; + this.setupWalletMode = "create"; + this.setupSeedStep = "write"; + this.walletVerified = false; + this.verifyChallenges = []; + this.verifyAnswers = {}; + await this.refresh(); + } catch (error) { + this.showSetupFeedback(error.message, "error"); + } + }, + + beginSeedVerification() { + this.setupFeedback = null; + const words = this.setupSeedWords(); + if (words.length < 4) { + this.showSetupFeedback("Generate a recovery phrase first", "error"); + return; + } + const positions = words.map((_, index) => index); + for (let index = positions.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)); + [positions[index], positions[swapIndex]] = [positions[swapIndex], positions[index]]; + } + this.verifyChallenges = positions + .slice(0, 4) + .sort((left, right) => left - right) + .map((index) => ({ index, position: index + 1 })); + this.verifyAnswers = {}; + for (const challenge of this.verifyChallenges) { + this.verifyAnswers[challenge.index] = ""; + } + this.setupSeedStep = "verify"; + }, + + verifyGeneratedSeed() { + const words = this.setupSeedWords(); + const ok = this.verifyChallenges.every((challenge) => { + const expected = words[challenge.index] || ""; + const actual = (this.verifyAnswers[challenge.index] || "").trim().toLowerCase(); + return actual === expected; + }); + if (!ok) { + this.showSetupFeedback("Seed word check failed", "error"); + return; + } + this.walletVerified = true; + this.setupSeedStep = "verified"; + this.showSetupFeedback("Recovery phrase verified", "success"); + }, + + skipSeedVerificationForDev() { + if (!this.setupWallet.dev_verify_bypass) return; + this.walletVerified = true; + this.setupSeedStep = "verified"; + this.showSetupFeedback("Recovery phrase verification skipped", "success"); + }, + + async importSetupSeed() { + try { + this.setupFeedback = null; + const payload = await this.postWalletSetup("/api/wallet/import", { + seed_phrase: this.importSeedPhrase, + }); + this.setupWallet = payload; + this.generatedSeedPhrase = ""; + this.verifyChallenges = []; + this.verifyAnswers = {}; + this.walletVerified = true; + this.setupSeedStep = "verified"; + await this.refresh(); + this.showSetupFeedback("Recovery phrase imported", "success"); + } catch (error) { + this.showSetupFeedback(error.message, "error"); + } + }, + + async postWalletSetup(path, fields) { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(fields)) { + body.set(key, value); + } + const response = await fetch(path, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + throw new Error(payload.error || `${path} returned ${response.status}`); + } + return payload; + }, + + async completeSetup() { + try { + if (!this.walletVerified) { + throw new Error("Verify or import a recovery phrase first"); + } + const response = await fetch("/api/config", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ setup_complete: "true" }), + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + throw new Error(payload.error || `/api/config returned ${response.status}`); + } + await this.refreshConfig(); + this.setupFeedback = null; + this.generatedSeedPhrase = ""; + this.importSeedPhrase = ""; + this.verifyChallenges = []; + this.verifyAnswers = {}; + this.showFlash("Setup complete", "success"); + this.setTab("wallet"); + } catch (error) { + this.showSetupFeedback(error.message, "error"); + } + }, + + async refresh() { + try { + const [config, status, blocks, mempool, peers] = await Promise.all([ + this.fetchJson("/api/config"), + this.fetchJson("/api/status"), + this.fetchJson("/api/blocks"), + this.fetchJson("/api/mempool"), + this.fetchJson("/api/peers"), + ]); + this.config = config; + if (!this.config.setup_complete) { + await this.refreshWalletSetup(); + } + this.status = status; + this.mergeFreshBlocks(blocks, { animateHead: true }); + this.mempool = mempool; + this.peers = peers; + this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; + if (!this.burnAmountDirty) { + this.burnAmountDraft = this.burnAmount; + } + this.lastUpdated = new Date(); + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + async fetchJson(path) { + const response = await fetch(path, { headers: { Accept: "application/json" } }); + if (!response.ok) { + throw new Error(`${path} returned ${response.status}`); + } + return response.json(); + }, + + mergeFreshBlocks(freshBlocks, options = {}) { + const previousHeights = new Set(this.blocks.map((block) => block.height)); + const previousHead = this.blocks[0]?.height; + const previousHeadHash = this.blocks[0]?.hash; + const wasFollowingHead = + !this.selectedBlock || (previousHeadHash && this.selectedBlock.hash === previousHeadHash); + const rail = this.$refs.blockRail; + const previousScrollWidth = rail?.scrollWidth ?? 0; + const known = new Map(this.blocks.map((block) => [block.hash, block])); + for (const block of freshBlocks) { + known.set(block.hash, block); + } + this.blocks = Array.from(known.values()).sort((left, right) => right.height - left.height); + const currentHead = this.blocks[0] || null; + if (wasFollowingHead) { + this.selectedBlock = currentHead; + } else if (!this.selectedBlock || !known.has(this.selectedBlock.hash)) { + this.selectedBlock = this.blocks[0] || null; + } else { + this.selectedBlock = known.get(this.selectedBlock.hash); + } + this.hasMoreBlocks = + this.blocks.some((block) => block.height > 0) && + !this.blocks.some((block) => block.height === 0); + + const newHeadBlocks = options.animateHead + ? this.blocks.filter( + (block) => + !previousHeights.has(block.height) && + (typeof previousHead !== "number" || block.height > previousHead) + ) + : []; + if (newHeadBlocks.length > 0) { + this.markNewBlocks(newHeadBlocks.map((block) => block.hash)); + this.$nextTick(() => + this.slideNewHeadBlocks(previousScrollWidth, { force: wasFollowingHead }) + ); + } + this.$nextTick(() => this.maybeLoadOlderBlocksFromRail()); + }, + + markNewBlocks(hashes) { + this.newBlockHashes = new Set(hashes); + if (this.newBlockTimer) { + clearTimeout(this.newBlockTimer); + } + this.newBlockTimer = setTimeout(() => { + this.newBlockHashes = new Set(); + this.newBlockTimer = null; + }, 650); + }, + + slideNewHeadBlocks(previousScrollWidth, options = {}) { + const rail = this.$refs.blockRail; + if (!rail || previousScrollWidth === 0 || (!options.force && rail.scrollLeft > 4)) return; + const addedWidth = rail.scrollWidth - previousScrollWidth; + if (addedWidth <= 0) return; + rail.scrollLeft = addedWidth; + rail.scrollTo({ left: 0, behavior: "smooth" }); + }, + + selectBlock(block) { + this.selectedBlock = block; + }, + + async loadOlderBlocks() { + if (this.loadingOlder || !this.hasMoreBlocks || this.blocks.length === 0) return; + const oldest = Math.min(...this.blocks.map((block) => block.height)); + if (oldest <= 0) { + this.hasMoreBlocks = false; + return; + } + this.loadingOlder = true; + try { + 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); + } catch (error) { + this.showFlash(error.message, "error"); + } finally { + this.loadingOlder = false; + } + }, + + maybeLoadOlderBlocks(event) { + 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 <= 180) { + this.loadOlderBlocks(); + } + }, + + async postForm(path, fields, successMessage) { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(fields)) { + body.set(key, value); + } + const response = await fetch(path, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + throw new Error(payload.error || `${path} returned ${response.status}`); + } + await this.refresh(); + this.showFlash(successMessage, "success"); + }, + + async saveBurn() { + try { + const amount = Math.max(0, Math.trunc(Number(this.burnAmountDraft) || 0)); + this.burnAmountDraft = amount; + await this.postForm( + "/api/settings/burn-per-block", + { amount }, + `Burn rate set to ${amount} LUUN per block` + ); + this.burnAmountDirty = false; + this.burnAmount = amount; + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + automaticBurnFeeDraft() { + const amount = Math.max(0, Math.trunc(Number(this.burnAmountDraft) || 0)); + const savedFee = this.status.mining?.automatic_burn_fee ?? 1; + return Math.min(savedFee || 1, Math.max(amount - 1, 0)); + }, + + async sendTransfer() { + try { + const amount = this.transferAmount; + const fee = this.transferFee; + const recipient = this.short(this.transferTo); + await this.postForm( + "/api/transfer", + { to: this.transferTo, amount, fee }, + `Queued transfer of ${amount} LUUN to ${recipient} with ${fee} fee` + ); + this.transferTo = ""; + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + async addPeer() { + try { + const peer = this.peerAddress; + await this.postForm("/api/peers", { peer }, `Added peer ${peer}`); + this.peerAddress = ""; + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + async copyAddress() { + try { + await navigator.clipboard.writeText(this.setupAddress()); + this.showFlash("Address copied", "success"); + } catch (error) { + this.showFlash("Could not copy address", "error"); + } + }, + + showFlash(message, kind) { + this.flash = { message, kind }; + if (this.flashTimer) { + clearTimeout(this.flashTimer); + } + this.flashTimer = setTimeout(() => { + this.flash = null; + this.flashTimer = null; + }, kind === "error" ? 7000 : 3500); + }, + + showSetupFeedback(message, kind) { + this.setupFeedback = { message, kind }; + }, + + short(value) { + if (!value) return "-"; + if (value.length <= 16) return value; + return `${value.slice(0, 8)}...${value.slice(-8)}`; + }, + + blockBurned(block) { + return block.transactions + .filter((tx) => tx.kind === "burn") + .reduce((sum, tx) => sum + tx.amount, 0); + }, + + blockBurnCount(block) { + return block.transactions.filter((tx) => tx.kind === "burn").length; + }, + + blockTransferCount(block) { + return block.transactions.filter((tx) => tx.kind === "transfer").length; + }, + + burnCountLabel(block) { + const count = this.blockBurnCount(block); + return `${count} burn${count === 1 ? "" : "s"}`; + }, + + transferCountLabel(block) { + const count = this.blockTransferCount(block); + return `${count} transfer${count === 1 ? "" : "s"}`; + }, + + blockMinerLabel(block) { + const miner = this.short(block.miner); + return block.miner === this.status.wallet_address ? `${miner} (me)` : miner; + }, + + 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"; + }, + + sharedHeightLabel() { + const local = this.status.chain?.height; + if (typeof local !== "number") return "-"; + const peerHeights = this.peers + .filter((peer) => !peer.last_error) + .map((peer) => peer.last_known_height) + .filter((height) => typeof height === "number"); + if (peerHeights.length === 0) return local; + return Math.min(local, ...peerHeights); + }, + + targetSecondsLabel() { + const ms = this.status.mining?.vdf_target_block_ms; + return ms ? `${Math.round(ms / 1000)}s` : "-"; + }, + + lastUpdatedLabel() { + return this.lastUpdated ? `Updated ${this.lastUpdated.toLocaleTimeString()}` : "Loading"; + }, + }; +}; diff --git a/assets/mivora-ui.js b/assets/mivora-ui.js @@ -1,591 +0,0 @@ -window.mivoraApp = function mivoraApp() { - return { - tab: "wallet", - status: {}, - blocks: [], - selectedBlock: null, - loadingOlder: false, - hasMoreBlocks: true, - mempool: [], - peers: [], - config: { setup_complete: false }, - setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false }, - setupWalletMode: "create", - setupSeedStep: "write", - generatedSeedPhrase: "", - verifyChallenges: [], - verifyAnswers: {}, - importSeedPhrase: "", - walletVerified: false, - setupFeedback: null, - burnAmount: 0, - burnAmountDraft: 0, - burnAmountDirty: false, - transferTo: "", - transferAmount: null, - transferFee: 1, - peerAddress: "", - showBurnTransactions: false, - flash: null, - flashTimer: null, - lastUpdated: null, - pollHandle: null, - newBlockHashes: new Set(), - newBlockTimer: null, - blockPageSize: 20, - - init() { - this.bootstrap(); - }, - - async bootstrap() { - await this.refreshConfig(); - if (!this.config.setup_complete) { - await this.refreshWalletSetup(); - } - this.tab = this.tabFromHash(); - window.addEventListener("hashchange", () => { - this.tab = this.tabFromHash(); - }); - await 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; - } - }, - - pageTitle() { - return { - wallet: "Mivora", - mining: "Mining", - p2p: "P2P", - chain: "Chain", - }[this.tab] || "Mivora"; - }, - - showingSetup() { - return !this.config.setup_complete; - }, - - async refreshConfig() { - this.config = await this.fetchJson("/api/config"); - }, - - async refreshWalletSetup() { - const payload = await this.fetchJson("/api/wallet/setup"); - if (!payload.ok) { - throw new Error(payload.error || "Could not load wallet setup"); - } - this.setupWallet = payload; - if ( - payload.seed_phrase && - payload.seed_phrase !== this.generatedSeedPhrase && - this.setupWalletMode === "create" && - !this.walletVerified - ) { - this.generatedSeedPhrase = payload.seed_phrase; - this.walletVerified = false; - this.setupSeedStep = "write"; - this.verifyChallenges = []; - this.verifyAnswers = {}; - } - }, - - setupSeedWords() { - return this.generatedSeedPhrase ? this.generatedSeedPhrase.split(/\s+/) : []; - }, - - setupAddress() { - return this.setupWallet.address || this.status.wallet_address || "-"; - }, - - selectSetupWalletMode(mode) { - this.setupWalletMode = mode; - this.walletVerified = mode === "import" ? this.walletVerified && !this.generatedSeedPhrase : false; - this.setupFeedback = null; - }, - - async generateSetupSeed() { - try { - this.setupFeedback = null; - const payload = await this.postWalletSetup("/api/wallet/generate", {}); - this.setupWallet = payload; - this.generatedSeedPhrase = payload.seed_phrase || ""; - this.setupWalletMode = "create"; - this.setupSeedStep = "write"; - this.walletVerified = false; - this.verifyChallenges = []; - this.verifyAnswers = {}; - await this.refresh(); - } catch (error) { - this.showSetupFeedback(error.message, "error"); - } - }, - - beginSeedVerification() { - this.setupFeedback = null; - const words = this.setupSeedWords(); - if (words.length < 4) { - this.showSetupFeedback("Generate a recovery phrase first", "error"); - return; - } - const positions = words.map((_, index) => index); - for (let index = positions.length - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(Math.random() * (index + 1)); - [positions[index], positions[swapIndex]] = [positions[swapIndex], positions[index]]; - } - this.verifyChallenges = positions - .slice(0, 4) - .sort((left, right) => left - right) - .map((index) => ({ index, position: index + 1 })); - this.verifyAnswers = {}; - for (const challenge of this.verifyChallenges) { - this.verifyAnswers[challenge.index] = ""; - } - this.setupSeedStep = "verify"; - }, - - verifyGeneratedSeed() { - const words = this.setupSeedWords(); - const ok = this.verifyChallenges.every((challenge) => { - const expected = words[challenge.index] || ""; - const actual = (this.verifyAnswers[challenge.index] || "").trim().toLowerCase(); - return actual === expected; - }); - if (!ok) { - this.showSetupFeedback("Seed word check failed", "error"); - return; - } - this.walletVerified = true; - this.setupSeedStep = "verified"; - this.showSetupFeedback("Recovery phrase verified", "success"); - }, - - skipSeedVerificationForDev() { - if (!this.setupWallet.dev_verify_bypass) return; - this.walletVerified = true; - this.setupSeedStep = "verified"; - this.showSetupFeedback("Recovery phrase verification skipped", "success"); - }, - - async importSetupSeed() { - try { - this.setupFeedback = null; - const payload = await this.postWalletSetup("/api/wallet/import", { - seed_phrase: this.importSeedPhrase, - }); - this.setupWallet = payload; - this.generatedSeedPhrase = ""; - this.verifyChallenges = []; - this.verifyAnswers = {}; - this.walletVerified = true; - this.setupSeedStep = "verified"; - await this.refresh(); - this.showSetupFeedback("Recovery phrase imported", "success"); - } catch (error) { - this.showSetupFeedback(error.message, "error"); - } - }, - - async postWalletSetup(path, fields) { - const body = new URLSearchParams(); - for (const [key, value] of Object.entries(fields)) { - body.set(key, value); - } - const response = await fetch(path, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, - body, - }); - const payload = await response.json(); - if (!response.ok || !payload.ok) { - throw new Error(payload.error || `${path} returned ${response.status}`); - } - return payload; - }, - - async completeSetup() { - try { - if (!this.walletVerified) { - throw new Error("Verify or import a recovery phrase first"); - } - const response = await fetch("/api/config", { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ setup_complete: "true" }), - }); - const payload = await response.json(); - if (!response.ok || !payload.ok) { - throw new Error(payload.error || `/api/config returned ${response.status}`); - } - await this.refreshConfig(); - this.setupFeedback = null; - this.generatedSeedPhrase = ""; - this.importSeedPhrase = ""; - this.verifyChallenges = []; - this.verifyAnswers = {}; - this.showFlash("Setup complete", "success"); - this.setTab("wallet"); - } catch (error) { - this.showSetupFeedback(error.message, "error"); - } - }, - - async refresh() { - try { - const [config, status, blocks, mempool, peers] = await Promise.all([ - this.fetchJson("/api/config"), - this.fetchJson("/api/status"), - this.fetchJson("/api/blocks"), - this.fetchJson("/api/mempool"), - this.fetchJson("/api/peers"), - ]); - this.config = config; - if (!this.config.setup_complete) { - await this.refreshWalletSetup(); - } - this.status = status; - this.mergeFreshBlocks(blocks, { animateHead: true }); - this.mempool = mempool; - this.peers = peers; - this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; - if (!this.burnAmountDirty) { - this.burnAmountDraft = this.burnAmount; - } - this.lastUpdated = new Date(); - } catch (error) { - this.showFlash(error.message, "error"); - } - }, - - async fetchJson(path) { - const response = await fetch(path, { headers: { Accept: "application/json" } }); - if (!response.ok) { - throw new Error(`${path} returned ${response.status}`); - } - return response.json(); - }, - - mergeFreshBlocks(freshBlocks, options = {}) { - const previousHeights = new Set(this.blocks.map((block) => block.height)); - const previousHead = this.blocks[0]?.height; - const previousHeadHash = this.blocks[0]?.hash; - const wasFollowingHead = - !this.selectedBlock || (previousHeadHash && this.selectedBlock.hash === previousHeadHash); - const rail = this.$refs.blockRail; - const previousScrollWidth = rail?.scrollWidth ?? 0; - const known = new Map(this.blocks.map((block) => [block.hash, block])); - for (const block of freshBlocks) { - known.set(block.hash, block); - } - this.blocks = Array.from(known.values()).sort((left, right) => right.height - left.height); - const currentHead = this.blocks[0] || null; - if (wasFollowingHead) { - this.selectedBlock = currentHead; - } else if (!this.selectedBlock || !known.has(this.selectedBlock.hash)) { - this.selectedBlock = this.blocks[0] || null; - } else { - this.selectedBlock = known.get(this.selectedBlock.hash); - } - this.hasMoreBlocks = - this.blocks.some((block) => block.height > 0) && - !this.blocks.some((block) => block.height === 0); - - const newHeadBlocks = options.animateHead - ? this.blocks.filter( - (block) => - !previousHeights.has(block.height) && - (typeof previousHead !== "number" || block.height > previousHead) - ) - : []; - if (newHeadBlocks.length > 0) { - this.markNewBlocks(newHeadBlocks.map((block) => block.hash)); - this.$nextTick(() => - this.slideNewHeadBlocks(previousScrollWidth, { force: wasFollowingHead }) - ); - } - this.$nextTick(() => this.maybeLoadOlderBlocksFromRail()); - }, - - markNewBlocks(hashes) { - this.newBlockHashes = new Set(hashes); - if (this.newBlockTimer) { - clearTimeout(this.newBlockTimer); - } - this.newBlockTimer = setTimeout(() => { - this.newBlockHashes = new Set(); - this.newBlockTimer = null; - }, 650); - }, - - slideNewHeadBlocks(previousScrollWidth, options = {}) { - const rail = this.$refs.blockRail; - if (!rail || previousScrollWidth === 0 || (!options.force && rail.scrollLeft > 4)) return; - const addedWidth = rail.scrollWidth - previousScrollWidth; - if (addedWidth <= 0) return; - rail.scrollLeft = addedWidth; - rail.scrollTo({ left: 0, behavior: "smooth" }); - }, - - selectBlock(block) { - this.selectedBlock = block; - }, - - async loadOlderBlocks() { - if (this.loadingOlder || !this.hasMoreBlocks || this.blocks.length === 0) return; - const oldest = Math.min(...this.blocks.map((block) => block.height)); - if (oldest <= 0) { - this.hasMoreBlocks = false; - return; - } - this.loadingOlder = true; - try { - 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); - } catch (error) { - this.showFlash(error.message, "error"); - } finally { - this.loadingOlder = false; - } - }, - - maybeLoadOlderBlocks(event) { - 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 <= 180) { - this.loadOlderBlocks(); - } - }, - - async postForm(path, fields, successMessage) { - const body = new URLSearchParams(); - for (const [key, value] of Object.entries(fields)) { - body.set(key, value); - } - const response = await fetch(path, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, - body, - }); - const payload = await response.json(); - if (!response.ok || !payload.ok) { - throw new Error(payload.error || `${path} returned ${response.status}`); - } - await this.refresh(); - this.showFlash(successMessage, "success"); - }, - - async saveBurn() { - try { - const amount = Math.max(0, Math.trunc(Number(this.burnAmountDraft) || 0)); - this.burnAmountDraft = amount; - await this.postForm( - "/api/settings/burn-per-block", - { amount }, - `Burn rate set to ${amount} coin(s) per block` - ); - this.burnAmountDirty = false; - this.burnAmount = amount; - } catch (error) { - this.showFlash(error.message, "error"); - } - }, - - automaticBurnFeeDraft() { - const amount = Math.max(0, Math.trunc(Number(this.burnAmountDraft) || 0)); - const savedFee = this.status.mining?.automatic_burn_fee ?? 1; - return Math.min(savedFee || 1, Math.max(amount - 1, 0)); - }, - - async sendTransfer() { - try { - const amount = this.transferAmount; - const fee = this.transferFee; - const recipient = this.short(this.transferTo); - await this.postForm( - "/api/transfer", - { to: this.transferTo, amount, fee }, - `Queued transfer of ${amount} coin(s) to ${recipient} with ${fee} fee` - ); - this.transferTo = ""; - } catch (error) { - this.showFlash(error.message, "error"); - } - }, - - async addPeer() { - try { - const peer = this.peerAddress; - await this.postForm("/api/peers", { peer }, `Added peer ${peer}`); - this.peerAddress = ""; - } catch (error) { - this.showFlash(error.message, "error"); - } - }, - - async copyAddress() { - try { - await navigator.clipboard.writeText(this.setupAddress()); - this.showFlash("Address copied", "success"); - } catch (error) { - this.showFlash("Could not copy address", "error"); - } - }, - - showFlash(message, kind) { - this.flash = { message, kind }; - if (this.flashTimer) { - clearTimeout(this.flashTimer); - } - this.flashTimer = setTimeout(() => { - this.flash = null; - this.flashTimer = null; - }, kind === "error" ? 7000 : 3500); - }, - - showSetupFeedback(message, kind) { - this.setupFeedback = { message, kind }; - }, - - short(value) { - if (!value) return "-"; - if (value.length <= 16) return value; - return `${value.slice(0, 8)}...${value.slice(-8)}`; - }, - - blockBurned(block) { - return block.transactions - .filter((tx) => tx.kind === "burn") - .reduce((sum, tx) => sum + tx.amount, 0); - }, - - blockBurnCount(block) { - return block.transactions.filter((tx) => tx.kind === "burn").length; - }, - - blockTransferCount(block) { - return block.transactions.filter((tx) => tx.kind === "transfer").length; - }, - - burnCountLabel(block) { - const count = this.blockBurnCount(block); - return `${count} burn${count === 1 ? "" : "s"}`; - }, - - transferCountLabel(block) { - const count = this.blockTransferCount(block); - return `${count} transfer${count === 1 ? "" : "s"}`; - }, - - blockMinerLabel(block) { - const miner = this.short(block.miner); - return block.miner === this.status.wallet_address ? `${miner} (me)` : miner; - }, - - 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"; - }, - - sharedHeightLabel() { - const local = this.status.chain?.height; - if (typeof local !== "number") return "-"; - const peerHeights = this.peers - .filter((peer) => !peer.last_error) - .map((peer) => peer.last_known_height) - .filter((height) => typeof height === "number"); - if (peerHeights.length === 0) return local; - return Math.min(local, ...peerHeights); - }, - - targetSecondsLabel() { - const ms = this.status.mining?.vdf_target_block_ms; - return ms ? `${Math.round(ms / 1000)}s` : "-"; - }, - - lastUpdatedLabel() { - return this.lastUpdated ? `Updated ${this.lastUpdated.toLocaleTimeString()}` : "Loading"; - }, - }; -}; diff --git a/devlogs/001-node-first.md b/devlogs/001-node-first.md @@ -1,17 +1,17 @@ # Devlog 001: Node First -Mivora starts from a running node first, then lets the explanation grow around the code. +Luun 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. +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 Luun feel alive as quickly as possible. -The important design choice is the hexagonal split. The coin rules live in the domain layer. The TCP server and HTTP UI sit outside that. Because of that, tests can run a little Mivora network entirely in memory, without ports, sleeps, containers, or a pretend deployment. +The important design choice is the hexagonal split. The Luun rules live in the domain layer. The TCP server and HTTP UI sit outside that. Because of that, tests can run a little Luun network entirely in memory, without ports, sleeps, containers, or a pretend deployment. The consensus sketch is intentionally small: -- burn coins into a block, +- burn LUUN 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. +- forget the stake because the LUUN 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/devlogs/002-vdf-clock.md b/devlogs/002-vdf-clock.md @@ -1,6 +1,6 @@ # Devlog 002: The VDF Is The Clock -The first UI had a "mine next block" button. That was useful for proving the ledger worked, but it was the wrong feeling for Mivora. +The first UI had a "mine next block" button. That was useful for proving the ledger worked, but it was the wrong feeling for Luun. Now the node runs by itself. Each wallet has a fixed burn amount. If that amount is above zero, once per chain height the node creates a burn transaction for that amount. Those burns become lottery tickets in the block, and the latest block's burns choose who gets to make the next block. @@ -10,4 +10,4 @@ The code also had to move the VDF outside the main node lock. If the VDF is supp The management page is also starting to feel less like a toy console and more like a tiny node dashboard. It shows the current leader, the fixed block reward, the burn setting, recent blocks, and what peers the node knows about. -Still friendly-node land. Still deliberately simple. But the rhythm is closer to the actual coin idea now. +Still friendly-node land. Still deliberately simple. But the rhythm is closer to the actual Luun idea now. diff --git a/devlogs/003-friend-join.md b/devlogs/003-friend-join.md @@ -6,8 +6,8 @@ The better flow is: I start a chain, you point your node at mine, and your node So the P2P port now does one extra friendly thing. When a node connects, the peer sends a chain snapshot: genesis allocations, VDF rounds, and the blocks it has. A joining node imports that snapshot before it starts mining. If it cannot get the snapshot, it refuses to start a separate chain. -The default burn is now zero. That matters because a friend who just joined probably has no coins yet. They can still follow the chain, receive coins, and only then decide how much to burn per block. +The default burn is now zero. That matters because a friend who just joined probably has no LUUN yet. They can still follow the chain, receive LUUN, and only then decide how much to burn per block. -Genesis changed too. The starter does not begin rich anymore. The starter gets 1 synthetic coin in genesis and burns it immediately, so their visible balance is 0, but the chain has a first lottery ticket. That ticket lets the starter produce the first real reward block. +Genesis changed too. The starter does not begin rich anymore. The starter gets 1 synthetic Luun in genesis and burns it immediately, so their visible balance is 0, but the chain has a first lottery ticket. That ticket lets the starter produce the first real reward block. -This is still not real adversarial sync. It trusts the friend you join. But for the current Mivora phase, that is exactly the point: make a small network feel real first, then harden it later. +This is still not real adversarial sync. It trusts the friend you join. But for the current Luun phase, that is exactly the point: make a small network feel real first, then harden it later. diff --git a/devlogs/004-wallet-file.md b/devlogs/004-wallet-file.md @@ -2,8 +2,8 @@ The node no longer has a baked-in dev wallet seed. -On first real startup, `--start` or `--join`, Mivora creates a wallet file and reuses it next time. The default is `.mivora/wallet.json`, or `<data-dir>/wallet.json` when a node uses its own data directory. +On first real startup, `--start` or `--join`, Luun creates a wallet file and reuses it next time. The default is `.luun/wallet.json`, or `<data-dir>/wallet.json` when a node uses its own data directory. -That matters for friend testing. You can restart your node and keep the same address, but friends do not need to pass a seed just to be someone else. They join your chain, get their own fresh local wallet, and start with 0 coins until you send them some. +That matters for friend testing. You can restart your node and keep the same address, but friends do not need to pass a seed just to be someone else. They join your chain, get their own fresh local wallet, and start with 0 LUUN until you send them some. This is still prototype-wallet simple: the file contains the seed, so it should be treated like a private key. diff --git a/devlogs/005-burned-blocks-and-vdf.md b/devlogs/005-burned-blocks-and-vdf.md @@ -6,4 +6,4 @@ That means a block has to carry at least one positive burn transaction. Otherwis The VDF also now runs over the candidate block content hash instead of just the previous hash. So if the leader changes the timestamp, miner, reward, rounds, previous hash, or transactions after doing the VDF, peers reject it. -One practical consequence: the default genesis still leaves the starter wallet at 0, so it creates the chain but waits. For a moving local demo, start with one extra genesis coin and burn it into block 1. +One practical consequence: the default genesis still leaves the starter wallet at 0, so it creates the chain but waits. For a moving local demo, start with one extra genesis Luun and burn it into block 1. diff --git a/devlogs/008-persistent-peer-sessions.md b/devlogs/008-persistent-peer-sessions.md @@ -4,6 +4,6 @@ The old P2P layer opened a fresh TCP connection for almost every little thing: s The new layer keeps one outbound session per known peer. Each peer gets a bounded queue, a reconnect loop with backoff, and a simple line-based message stream. Status messages keep flowing over the same connection, and if a peer reports that it is ahead, the node asks for the missing block range on that same session. -This is still intentionally small. It is not trying to be libp2p. But it is much closer to how the coin should behave: peers stay connected, gossip is queued instead of redialed, quiet disconnects are treated as normal, and catch-up is driven by the protocol instead of a separate polling fetch path. +This is still intentionally small. It is not trying to be libp2p. But it is much closer to how the Luun should behave: peers stay connected, gossip is queued instead of redialed, quiet disconnects are treated as normal, and catch-up is driven by the protocol instead of a separate polling fetch path. The important part for testing is that the node core did not become network-shaped. The session layer is still an adapter around the same `GossipEnvelope` messages, so the fast deterministic tests can keep exercising the protocol without real sockets. diff --git a/devlogs/009-longer-fork-reorgs.md b/devlogs/009-longer-fork-reorgs.md @@ -1,6 +1,6 @@ # Devlog 009: Longer Fork Reorgs -Until now, Mivora mostly behaved like there was only one possible chain. If a snapshot disagreed with a block we already had, the node rejected it. That is nice and simple, but it is not how a real network behaves. Two friendly nodes can still mine competing blocks if messages arrive in a weird order. +Until now, Luun mostly behaved like there was only one possible chain. If a snapshot disagreed with a block we already had, the node rejected it. That is nice and simple, but it is not how a real network behaves. Two friendly nodes can still mine competing blocks if messages arrive in a weird order. The new rule is intentionally small: a remote chain can replace the local chain only if it has the same genesis, fully validates, shares a common ancestor, and is strictly longer. Same-height forks do not cause flip-flopping. The node waits until one side grows longer. diff --git a/devlogs/010-hello-and-inventory.md b/devlogs/010-hello-and-inventory.md @@ -2,7 +2,7 @@ The P2P protocol now starts with a real `Hello`. A node tells the peer its protocol version, network id, genesis hash, listen address, height, and tip hash. If the protocol, network, or genesis does not match, the session is rejected early. -That matters because "it connected" is not enough for a coin. A node on a different genesis should not be able to quietly trade blocks with us and create weird local errors later. +That matters because "it connected" is not enough for a Luun. A node on a different genesis should not be able to quietly trade blocks with us and create weird local errors later. Gossip also changed. Instead of pushing full transactions and blocks every time, nodes announce inventory: transaction signatures and block hashes. Peers then request only the objects they do not have yet. diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -102,7 +102,7 @@ pub async fn serve( let app = Router::new() .route("/", get(index)) .route("/assets/alpine.min.js", get(alpine_js)) - .route("/assets/mivora-ui.js", get(app_js)) + .route("/assets/luun-ui.js", get(app_js)) .route("/api/status", get(api_status)) .route("/api/blocks", get(api_blocks)) .route("/api/config", get(api_config).post(api_config_form)) @@ -149,7 +149,7 @@ async fn app_js() -> impl IntoResponse { header::CONTENT_TYPE, "application/javascript; charset=utf-8", )], - include_str!("../../assets/mivora-ui.js"), + include_str!("../../assets/luun-ui.js"), ) } @@ -368,7 +368,7 @@ fn wallet_setup_json(result: Result<WalletSetupResponse>) -> Json<WalletSetupRes } fn dev_seed_verify_bypass_enabled() -> bool { - dev_seed_verify_bypass_allowed(std::env::var_os("MIVORA_DEV_SKIP_SEED_VERIFY").is_some()) + dev_seed_verify_bypass_allowed(std::env::var_os("LUUN_DEV_SKIP_SEED_VERIFY").is_some()) } fn dev_seed_verify_bypass_allowed(env_present: bool) -> bool { @@ -428,7 +428,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Mivora</title> + <title>Luun</title> <style> [x-cloak] { display: none !important; } :root { @@ -577,13 +577,13 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/mivora-ui.js?v=31"></script> + <script defer src="/assets/luun-ui.js?v=31"></script> <script defer src="/assets/alpine.min.js"></script> </head> -<body x-data="mivoraApp()" x-init="init()" x-cloak> +<body x-data="luunApp()" x-init="init()" x-cloak> <div class="app-shell"> - <aside class="sidebar" aria-label="Mivora navigation"> - <div class="brand-mark" title="Mivora">M</div> + <aside class="sidebar" aria-label="Luun navigation"> + <div class="brand-mark" title="Luun">L</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> @@ -607,7 +607,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <main class="content"> <header> <div> - <h1 x-text="pageTitle()">Mivora</h1> + <h1 x-text="pageTitle()">Luun</h1> </div> <div class="muted" x-text="lastUpdatedLabel()"></div> </header> @@ -650,8 +650,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="wallet-tx-row" :class="{ pending: tx.status === 'pending' }"> <span class="pill" :class="tx.kind" x-text="tx.direction"></span> <div class="wallet-tx-main"> - <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">MVR$ <span x-text="tx.amount"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">MVR$ <span x-text="tx.fee ?? 0"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="tx.amount"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="tx.fee ?? 0"></span></span></div> <div class="tx-field"><span class="tx-label">Status</span><span class="tx-value text" x-text="txTitle(tx)"></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(tx.from)"></code></div> <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(tx.to)"></code></div> @@ -682,7 +682,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="panel"> <h3>Mining</h3> <form @submit.prevent="saveBurn"> - <label>Coins per block<input x-model.number="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0"></label> + <label>LUUN per block<input x-model.number="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0"></label> <label>Fee<input :value="automaticBurnFeeDraft()" type="number" readonly></label> <button class="primary" type="submit">Save</button> </form> @@ -759,8 +759,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in selectedBlock.transactions" :key="tx.signature"> <div class="tx-card"> <span class="pill" :class="tx.kind" x-text="tx.kind"></span> - <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">MVR$ <span x-text="tx.amount"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">MVR$ <span x-text="tx.fee ?? 0"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="tx.amount"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="tx.fee ?? 0"></span></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(tx.from)"></code></div> <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(tx.to)"></code></div> <div class="tx-field"><span class="tx-label">Nonce</span><span class="tx-value number" x-text="tx.nonce"></span></div> @@ -780,8 +780,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in mempool" :key="tx.signature"> <div class="mempool-item"> <span class="pill" :class="tx.kind" x-text="tx.kind"></span> - <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">MVR$ <span x-text="tx.amount"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">MVR$ <span x-text="tx.fee ?? 0"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="tx.amount"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="tx.fee ?? 0"></span></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(tx.from)"></code></div> <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(tx.to)"></code></div> <div class="tx-field"><span class="tx-label">Nonce</span><span class="tx-value number" x-text="tx.nonce"></span></div> @@ -797,7 +797,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="setup-overlay" x-show="showingSetup()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="setup-title"> <section class="setup-modal"> <div class="setup-modal-head"> - <div class="setup-welcome">Welcome to Mivora</div> + <div class="setup-welcome">Welcome to Luun</div> <h2 id="setup-title">Initial Setup</h2> <div class="setup-copy">Confirm the local wallet address and add any peers before this node starts from a saved configuration.</div> </div> diff --git a/src/app.rs b/src/app.rs @@ -19,7 +19,7 @@ pub type SharedPeerBook = Arc<Mutex<PeerBook>>; pub const DEFAULT_BURN_PER_BLOCK: Amount = 0; pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000; pub const PROTOCOL_VERSION: u32 = 1; -pub const NETWORK_ID: &str = "mivora-devnet-v0"; +pub const NETWORK_ID: &str = "luun-devnet-v0"; pub const BLOCK_REQUEST_LIMIT: usize = 128; const IMPORT_REBROADCAST_LIMIT: usize = 128; diff --git a/src/domain.rs b/src/domain.rs @@ -19,6 +19,7 @@ const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 10; const FORK_FINALITY_DEPTH: u64 = 6; const VDF_MODULUS: u128 = 4_611_685_975_477_714_963; const VDF_CHALLENGE_MIN: u64 = 1_073_741_827; +const WALLET_SEED_DOMAIN: &str = "luun-wallet-seed"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct Wallet { @@ -28,7 +29,7 @@ pub struct Wallet { impl Wallet { pub fn from_seed(seed: &str) -> Self { - let seed_hash = Sha256::digest(format!("mivora-wallet-seed:{seed}").as_bytes()); + let seed_hash = Sha256::digest(format!("{WALLET_SEED_DOMAIN}:{seed}").as_bytes()); let mut signing_seed = [0_u8; 32]; signing_seed.copy_from_slice(&seed_hash); let signing_key = SigningKey::from_bytes(&signing_seed); @@ -200,7 +201,7 @@ pub enum Transaction { impl Transaction { pub fn genesis_burn(from: impl Into<String>, amount: Amount) -> Self { let from = from.into(); - let signature = hex_hash(format!("mivora-genesis-burn:{from}:{amount}")); + let signature = hex_hash(format!("luun-genesis-burn:{from}:{amount}")); Self::Burn { from, amount, @@ -404,7 +405,7 @@ pub struct LeaderProof { impl LeaderProof { fn rank(&self) -> String { hex_hash(format!( - "mivora-leader-rank:{}:{}", + "luun-leader-rank:{}:{}", self.ticket_id, self.signature )) } @@ -423,7 +424,7 @@ struct LeaderProofPayload { impl LeaderProofPayload { fn canonical(&self) -> String { format!( - "mivora-leader-proof:{}:{}:{}:{}:{}:{}", + "luun-leader-proof:{}:{}:{}:{}:{}:{}", self.height, self.prev_hash, self.vdf_output, @@ -537,7 +538,7 @@ pub struct LaunchProfile { impl Default for LaunchProfile { fn default() -> Self { Self { - profile_id: "mivora-devnet-v2".to_string(), + profile_id: "luun-devnet-v2".to_string(), ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, max_pending_transactions: MAX_PENDING_TRANSACTIONS, max_block_transactions: MAX_BLOCK_TRANSACTIONS, @@ -553,7 +554,7 @@ fn default_max_block_bytes() -> usize { impl LaunchProfile { pub fn hash(&self) -> String { hex_hash(format!( - "mivora-launch-profile:{}:{}:{}:{}:{}", + "luun-launch-profile:{}:{}:{}:{}:{}", self.profile_id, self.ticket_maturity_delay_heights, self.max_pending_transactions, @@ -1355,7 +1356,7 @@ impl Ledger { fn ticket_rank(parent: &Block, target_height: u64, ticket: &BurnTicket) -> String { hex_hash(format!( - "mivora-ticket-rank:{}:{}:{}:{}:{}", + "luun-ticket-rank:{}:{}:{}:{}:{}", target_height, parent.hash, parent.vdf_output, ticket.id, ticket.amount )) } @@ -1401,12 +1402,16 @@ fn genesis_tickets( return Ok(tickets); } - let Some((owner, amount)) = genesis_allocations.iter().find(|(_, amount)| **amount > 0) else { + let Some((owner, amount)) = genesis_allocations + .iter() + .rev() + .find(|(_, amount)| **amount > 0) + else { return Ok(Vec::new()); }; Ok(vec![BurnTicket { id: hex_hash(format!( - "mivora-genesis-ticket:{owner}:{amount}:{}", + "luun-genesis-ticket:{owner}:{amount}:{}", genesis.hash )), owner: owner.clone(), @@ -1544,7 +1549,7 @@ fn verify_leader_signature(proof: &LeaderProof, payload: &LeaderProofPayload) -> } fn vdf_seed_for_child(prev_hash: &str, height: u64) -> String { - hex_hash(format!("mivora-vdf-child:{prev_hash}:{height}")) + hex_hash(format!("luun-vdf-child:{prev_hash}:{height}")) } fn apply_transaction( @@ -1626,7 +1631,7 @@ fn build_genesis_block( .map(Transaction::canonical) .collect::<Vec<_>>() .join("|"); - let vdf_output = hex_hash(format!("mivora-genesis-vdf:{genesis_allocations:?}:{txs}")); + let vdf_output = hex_hash(format!("luun-genesis-vdf:{genesis_allocations:?}:{txs}")); let mut genesis = Block { height: 0, prev_hash: "0".repeat(64), @@ -1751,14 +1756,14 @@ pub fn verify_vdf(seed: &str, rounds: u32, solution: &str) -> bool { } fn vdf_seed_element(seed: &str) -> u128 { - let digest = Sha256::digest(format!("mivora-vdf-seed:{seed}").as_bytes()); + let digest = Sha256::digest(format!("luun-vdf-seed:{seed}").as_bytes()); let mut bytes = [0_u8; 16]; bytes.copy_from_slice(&digest[..16]); 2 + (u128::from_be_bytes(bytes) % (VDF_MODULUS - 3)) } fn vdf_challenge_prime(seed: &str, rounds: u32, output: u128) -> u64 { - let digest = Sha256::digest(format!("mivora-vdf-challenge:{seed}:{rounds}:{output:x}")); + let digest = Sha256::digest(format!("luun-vdf-challenge:{seed}:{rounds}:{output:x}")); let mut bytes = [0_u8; 8]; bytes.copy_from_slice(&digest[..8]); let candidate = VDF_CHALLENGE_MIN + (u64::from_be_bytes(bytes) % VDF_CHALLENGE_MIN); diff --git a/src/main.rs b/src/main.rs @@ -8,7 +8,7 @@ use std::{ }; use anyhow::{Context, Result, bail}; -use mivora::{ +use luun::{ adapters::{chain_store::SqliteChainStore, config_store, http, p2p, wallet_store}, app::{NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms}, domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, run_vdf}, @@ -52,14 +52,14 @@ async fn main() -> Result<()> { persist_chain_snapshot(&chain_store, initial_snapshot).await?; } - println!("mivora wallet: {}", node.lock().await.wallet_address()); + println!("luun wallet: {}", node.lock().await.wallet_address()); println!("wallet file: {}", wallet_path.display()); println!("config file: {}", config_path.display()); println!("chain database: {}", chain_store.path().display()); println!("management UI: http://{}", opts.http_addr); println!("p2p listener: {}", opts.p2p_addr); println!( - "automatic mining: VDF-driven, burning {} coins per block", + "automatic mining: VDF-driven, burning {} LUUN per block", initial_burn_per_block ); @@ -166,7 +166,7 @@ impl CliOptions { peers: Vec::new(), join_peers: Vec::new(), chain_mode: ChainMode::Setup, - data_dir: PathBuf::from(".mivora"), + data_dir: PathBuf::from(".luun"), }; let raw_args = args.into_iter().collect::<Vec<_>>(); @@ -187,7 +187,7 @@ impl CliOptions { } "--wallet-seed" => { bail!( - "--wallet-seed was removed; wallets are stored in --wallet <path> or .mivora/wallet.json" + "--wallet-seed was removed; wallets are stored in --wallet <path> or .luun/wallet.json" ) } "--http" => { @@ -277,11 +277,11 @@ fn print_help() { } fn help_text() -> &'static str { - "mivora\n\n\ + "luun\n\n\ Usage:\n\ - mivora [options]\n\ - mivora --genesis [options]\n\ - mivora --join <addr:port> [options]\n\n\ + luun [options]\n\ + luun --genesis [options]\n\ + luun --join <addr:port> [options]\n\n\ Options:\n\ --genesis Create a new chain from an existing setup wallet\n\ --wallet <path> Wallet file (default <data-dir>/wallet.json)\n\ @@ -291,7 +291,7 @@ fn help_text() -> &'static str { --join <addr:port> Fetch chain snapshot from this peer before mining\n\ --data-dir <path> Local wallet directory\n\n\ Environment:\n\ - MIVORA_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" + LUUN_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" } fn snapshot_height(snapshot: &ChainSnapshot) -> u64 { @@ -321,7 +321,7 @@ fn start_genesis_ledger(wallet_address: &str) -> Result<Ledger> { } fn measure_initial_vdf_rounds() -> u32 { - let seed = "mivora-vdf-calibration"; + let seed = "luun-vdf-calibration"; let (measured_rounds, elapsed) = measure_vdf_rounds( seed, VDF_MEASUREMENT_INITIAL_ROUNDS, @@ -517,7 +517,7 @@ async fn persist_chain_snapshot(store: &SqliteChainStore, snapshot: ChainSnapsho mod tests { use std::{collections::BTreeMap, sync::Arc, time::Duration}; - use mivora::{ + use luun::{ adapters::{chain_store::SqliteChainStore, config_store::UiConfig}, app::{DEFAULT_BURN_PER_BLOCK, NodeCore}, domain::{GenesisBurn, Ledger, Wallet}, @@ -538,11 +538,11 @@ mod tests { #[test] fn help_mentions_dev_seed_verify_bypass_env() { - assert!(help_text().contains("MIVORA_DEV_SKIP_SEED_VERIFY=1")); + assert!(help_text().contains("LUUN_DEV_SKIP_SEED_VERIFY=1")); assert!(help_text().contains("skip seed verification")); } - fn ledger_with_one_spendable_coin(wallet: &Wallet) -> Ledger { + fn ledger_with_one_spendable_luun(wallet: &Wallet) -> Ledger { let mut genesis = BTreeMap::new(); genesis.insert(wallet.address().to_string(), 2); Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) @@ -550,7 +550,7 @@ mod tests { } fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger { - let mut ledger = ledger_with_one_spendable_coin(wallet); + let mut ledger = ledger_with_one_spendable_luun(wallet); ledger .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) .unwrap(); @@ -659,7 +659,7 @@ mod tests { } #[test] - fn http_management_port_defaults_to_mivora_port() { + fn http_management_port_defaults_to_luun_port() { let opts = parse(&[]).unwrap().unwrap(); assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661"); } @@ -749,14 +749,13 @@ mod tests { #[test] fn vdf_measurement_keeps_sampling_until_elapsed_is_useful() { - let (rounds, elapsed) = measure_vdf_rounds( - "mivora-test-vdf-calibration", - 1, - Duration::from_millis(1), - 1_000_000, - ); + let min_elapsed = Duration::from_millis(1); + let max_rounds = 1_000_000; + let (rounds, elapsed) = + measure_vdf_rounds("luun-test-vdf-calibration", 1, min_elapsed, max_rounds); - assert!(rounds > 1); + assert!(rounds >= 1); + assert!(elapsed >= min_elapsed || rounds >= max_rounds); assert!(elapsed > Duration::ZERO); } @@ -867,7 +866,7 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0) let dir = tempdir().unwrap(); let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); let wallet = Wallet::from_seed("background-persistence"); - let ledger = ledger_with_one_spendable_coin(&wallet); + let ledger = ledger_with_one_spendable_luun(&wallet); let node = Arc::new(Mutex::new(NodeCore::from_ledger( wallet.clone(), ledger, diff --git a/tests/coin.rs b/tests/coin.rs @@ -1,1732 +0,0 @@ -use std::collections::BTreeMap; - -use mivora::{ - adapters::chain_store::SqliteChainStore, - app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection}, - domain::{ - Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, VDF_TARGET_BLOCK_MS, Wallet, - run_vdf, verify_vdf, - }, -}; -use tempfile::tempdir; - -fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { - NodeCore::new(NodeConfig { - wallet, - genesis_allocations: allocations, - vdf_rounds: 25, - burn_per_block: DEFAULT_BURN_PER_BLOCK, - }) -} - -fn wallets(names: &[&str]) -> Vec<Wallet> { - names.iter().map(|name| Wallet::from_seed(name)).collect() -} - -fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> { - wallets - .iter() - .map(|wallet| (wallet.address().to_string(), amount)) - .collect() -} - -fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) -> String { - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); - let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap(); - let hash = block.hash.clone(); - ledger.apply_block(block).unwrap(); - hash -} - -fn fork_with_better_vrf_block( - base: &Ledger, - wallet: &Wallet, - local_fork_block_hash: &str, - first_timestamp_ms: u64, -) -> Option<Ledger> { - for offset in 0..10_000 { - let mut candidate = base.clone(); - let hash = mine_wallet_burn_block(&mut candidate, wallet, first_timestamp_ms + offset); - if hash.as_str() < local_fork_block_hash { - return Some(candidate); - } - } - None -} - -fn fork_with_worse_vrf_block( - base: &Ledger, - wallet: &Wallet, - local_fork_block_hash: &str, - first_timestamp_ms: u64, -) -> Option<Ledger> { - for offset in 0..10_000 { - let mut candidate = base.clone(); - let hash = mine_wallet_burn_block(&mut candidate, wallet, first_timestamp_ms + offset); - if hash.as_str() > local_fork_block_hash { - return Some(candidate); - } - } - None -} - -fn starter_node(wallet: Wallet) -> NodeCore { - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25) - .unwrap(); - NodeCore::from_ledger(wallet, ledger, DEFAULT_BURN_PER_BLOCK) -} - -#[test] -fn genesis_burn_starts_chain_with_reward_and_first_leader() { - let alice = Wallet::from_seed("alice"); - let node = starter_node(alice.clone()); - - let genesis = &node.ledger().chain()[0]; - assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD); - assert_eq!(genesis.height, 0); - assert_eq!(genesis.miner, alice.address()); - assert_eq!(genesis.reward, BLOCK_REWARD); - assert_eq!(genesis.transactions.len(), 1); - assert!(genesis.transactions[0].is_burn()); - assert_eq!(genesis.transactions[0].amount(), 1); - assert_eq!( - node.ledger().expected_leader_for_next_block().as_deref(), - Some(alice.address()) - ); -} - -#[test] -fn starter_node_waits_for_a_burn_before_vdf_work() { - let alice = Wallet::from_seed("alice"); - let mut node = starter_node(alice.clone()); - - let outcome = node.automatic_mine_once(1); - assert!(outcome.burned.is_none()); - assert!(outcome.block.is_none()); - assert!( - outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("at least one burn")) - ); - assert_eq!(node.ledger().status().height, 0); - assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD); -} - -#[test] -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(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - - let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(20, ledger.next_nonce(alice.address()))) - .unwrap(); - ledger - .submit_transaction(bob.burn(80, ledger.next_nonce(bob.address()))) - .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 - } else { - &alice - }; - assert!(ledger.mine_next_block(non_leader, 2).is_err()); - - let leader_wallet = if expected == alice.address() { - &alice - } else { - &bob - }; - ledger - .submit_transaction(leader_wallet.burn(1, ledger.next_nonce(leader_wallet.address()))) - .unwrap(); - assert!(ledger.mine_next_block(leader_wallet, 2).is_ok()); -} - -#[test] -fn transfer_and_burn_update_balances_when_block_is_applied() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 100); - - let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.transfer(bob.address(), 125, ledger.next_nonce(alice.address()))) - .unwrap(); - ledger - .submit_transaction(alice.burn(25, ledger.next_nonce(alice.address()))) - .unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.balance_of(alice.address()), 950); - assert_eq!(ledger.balance_of(bob.address()), 225); -} - -#[test] -fn forged_transaction_is_rejected() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - let mut ledger = Ledger::new(allocations, 10); - - let mut forged = bob.burn(10, 1); - if let mivora::domain::Transaction::Burn { from, .. } = &mut forged { - *from = alice.address().to_string(); - } - - let error = ledger.submit_transaction(forged).unwrap_err(); - assert!(error.to_string().contains("signature")); -} - -#[test] -fn block_with_forged_transaction_is_rejected() { - 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); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .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"); - } - 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("signature")); -} - -#[test] -fn block_reward_is_fixed_at_one_hundred_coins() { - 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); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, BLOCK_REWARD); - - ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), 1_099); -} - -#[test] -fn transaction_fees_are_paid_to_the_block_miner() { - let alice = Wallet::from_seed("fee-miner-alice"); - let bob = Wallet::from_seed("fee-payer-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1); - allocations.insert(bob.address().to_string(), 200); - - let mut ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - ledger - .submit_transaction(bob.transfer_with_fee( - alice.address(), - 10, - 7, - ledger.next_nonce(bob.address()), - )) - .unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, BLOCK_REWARD + 7); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.balance_of(alice.address()), 216); - assert_eq!(ledger.balance_of(bob.address()), 183); -} - -#[test] -fn miner_orders_block_transactions_by_fee_rate_after_required_burn() { - let wallets = wallets(&["fee-order-alice", "fee-order-bob", "fee-order-carol"]); - let alice = &wallets[0]; - let bob = &wallets[1]; - let carol = &wallets[2]; - let mut allocations = allocations(&wallets, 1_000); - allocations.insert(alice.address().to_string(), 1); - let mut ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - let required_burn = alice.burn(1, ledger.next_nonce(alice.address())); - let low_fee = bob.transfer_with_fee(alice.address(), 1, 1, ledger.next_nonce(bob.address())); - let high_fee = - carol.transfer_with_fee(alice.address(), 1, 20, ledger.next_nonce(carol.address())); - ledger.submit_transaction(low_fee.clone()).unwrap(); - ledger.submit_transaction(high_fee.clone()).unwrap(); - ledger.submit_transaction(required_burn.clone()).unwrap(); - - let block = ledger.mine_next_block(alice, 1).unwrap(); - let signatures = block - .transactions - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<Vec<_>>(); - - assert_eq!(signatures[0], required_burn.signature()); - assert!( - signatures - .iter() - .position(|signature| signature == high_fee.signature()) - < signatures - .iter() - .position(|signature| signature == low_fee.signature()) - ); -} - -#[test] -fn oversized_blocks_are_rejected() { - let alice = Wallet::from_seed("oversized-block-alice"); - let bob = Wallet::from_seed("oversized-block-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1); - allocations.insert(bob.address().to_string(), 1_000); - let mut ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - let oversized_transfer = bob.transfer_with_fee( - "x".repeat(MAX_BLOCK_BYTES), - 1, - 3, - ledger.next_nonce(bob.address()), - ); - block.transactions.push(oversized_transfer); - block.reward = BLOCK_REWARD + 3; - block.hash = block.compute_hash(); - - let error = ledger.apply_block(block).unwrap_err(); - - assert!(format!("{error:#}").contains("max block size")); -} - -#[test] -fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() { - let wallets = wallets(&[ - "oversized-select-alice", - "oversized-select-bob", - "oversized-select-carol", - ]); - let alice = &wallets[0]; - let bob = &wallets[1]; - let carol = &wallets[2]; - let mut allocations = allocations(&wallets, 200_000); - allocations.insert(alice.address().to_string(), 1); - let mut ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let oversized = bob.transfer_with_fee( - "x".repeat(MAX_BLOCK_BYTES), - 1, - 100_000, - ledger.next_nonce(bob.address()), - ); - let fitting = - carol.transfer_with_fee(alice.address(), 1, 5, ledger.next_nonce(carol.address())); - ledger.submit_transaction(oversized.clone()).unwrap(); - ledger.submit_transaction(fitting.clone()).unwrap(); - - let block = ledger.mine_next_block(alice, 1).unwrap(); - let signatures = block - .transactions - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<Vec<_>>(); - - assert!(!signatures.contains(&oversized.signature().to_string())); - assert!(signatures.contains(&fitting.signature().to_string())); - assert!(block.serialized_size_bytes().unwrap() <= MAX_BLOCK_BYTES); -} - -#[test] -fn transfer_that_would_overflow_recipient_balance_is_rejected() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1); - allocations.insert(bob.address().to_string(), Amount::MAX); - - let mut ledger = Ledger::new(allocations, 10); - let error = ledger - .submit_transaction(alice.transfer(bob.address(), 1, ledger.next_nonce(alice.address()))) - .unwrap_err(); - - assert!(format!("{error:#}").contains("balance overflow")); -} - -#[test] -fn block_reward_that_would_overflow_miner_balance_is_rejected() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), Amount::MAX); - - let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - - let error = ledger.apply_block(block).unwrap_err(); - - assert!(format!("{error:#}").contains("balance overflow")); -} - -#[test] -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 vdf_work_requires_at_least_one_pending_burn() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - - let ledger = Ledger::new(allocations, 10); - let error = ledger.prepare_next_block(alice.address(), 1).unwrap_err(); - - assert!(format!("{error:#}").contains("at least one burn")); -} - -#[test] -fn leader_block_without_burn_is_rejected() { - 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); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - block.transactions.clear(); - block.hash = block.compute_hash(); - - let error = ledger.apply_block(block).unwrap_err(); - assert!(format!("{error:#}").contains("at least one burn")); -} - -#[test] -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); - - let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - block.timestamp_ms += 1; - - let error = ledger.apply_block(block).unwrap_err(); - assert!(error.to_string().contains("block hash is invalid")); -} - -#[test] -fn automatic_mining_burns_configured_amount_once_per_height() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - - let mut node = NodeCore::new(NodeConfig { - wallet: alice.clone(), - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: 25, - }); - - let first = node.automatic_mine_once(1); - assert!(first.burned.is_some()); - assert!(first.block.is_some()); - assert_eq!(node.ledger().chain().len(), 2); - assert_eq!(node.ledger().balance_of(alice.address()), 1_075); - - let second = node.automatic_mine_once(2); - assert!(second.burned.is_some()); - assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(25)); -} - -#[test] -fn default_automatic_mining_does_not_burn() { - assert_eq!(DEFAULT_BURN_PER_BLOCK, 0); - - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - let mut node = node("alice", alice.clone(), allocations); - - let outcome = node.automatic_mine_once(1); - assert!(outcome.burned.is_none()); - assert!(outcome.block.is_none()); - assert!( - outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("at least one burn")) - ); - assert_eq!(node.ledger().balance_of(alice.address()), 1_000); -} - -#[test] -fn burn_per_block_can_be_set_to_zero() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - let mut node = node("alice", alice, allocations); - - let burned = node.set_burn_per_block(25).unwrap(); - assert!(burned.is_some()); - assert_eq!(node.status().mining.burn_per_block, 25); - let burned = node.set_burn_per_block(0).unwrap(); - assert!(burned.is_none()); - assert_eq!(node.status().mining.burn_per_block, 0); -} - -#[test] -fn automatic_burn_status_shows_configured_fee() { - let alice = Wallet::from_seed("auto-fee-status-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - let mut node = node("alice", alice, allocations); - - assert_eq!(node.status().mining.automatic_burn_fee, 0); - - node.set_burn_per_block(1).unwrap(); - assert_eq!(node.status().mining.burn_per_block, 1); - assert_eq!(node.status().mining.automatic_burn_fee, 0); - - node.set_burn_per_block(50).unwrap(); - assert_eq!(node.status().mining.burn_per_block, 50); - assert_eq!(node.status().mining.automatic_burn_fee, 1); -} - -#[test] -fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 100); - - let mut ledger = Ledger::new(allocations.clone(), 25); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let first = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(first).unwrap(); - - let mut bob_node = node("bob", bob.clone(), allocations); - bob_node - .receive(mivora::app::GossipEnvelope::ChainSnapshot( - ledger.snapshot(), - )) - .unwrap(); - - let skipped = bob_node.automatic_mine_once(2); - assert!(skipped.burned.is_none()); - assert!(skipped.block.is_none()); - - let burned = bob_node.set_burn_per_block(1).unwrap(); - - assert!(burned.is_some()); - assert_eq!(bob_node.ledger().pending().len(), 1); - assert_eq!(bob_node.ledger().pending()[0].sender(), bob.address()); - assert_eq!(bob_node.ledger().pending()[0].amount(), 1); -} - -#[test] -fn automatic_mining_waits_when_wallet_is_not_selected_leader() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - - let mut ledger = Ledger::new(allocations.clone(), 25); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let first = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(first).unwrap(); - - let mut bob_node = node("bob", bob.clone(), allocations); - bob_node.set_burn_per_block(10).unwrap(); - bob_node - .receive(mivora::app::GossipEnvelope::Block( - ledger.chain()[1].clone(), - )) - .unwrap(); - - let outcome = bob_node.automatic_mine_once(2); - assert!(outcome.burned.is_some()); - assert!(outcome.block.is_none()); - assert!(outcome.skipped_reason.unwrap().contains(alice.address())); - assert_eq!(bob_node.ledger().chain().len(), 2); -} - -#[test] -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_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, 1).unwrap(); - block.vdf_rounds = 1; - block.hash = block.compute_hash(); - block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds); - - assert!(ledger.apply_block(block).is_err()); -} - -#[test] -fn vdf_solution_verifies_without_rerunning_delay() { - let solution = run_vdf("test-seed", 128); - - assert!(verify_vdf("test-seed", 128, &solution)); - assert!(!verify_vdf("other-seed", 128, &solution)); - assert!(!verify_vdf("test-seed", 129, &solution)); - assert!(!verify_vdf("test-seed", 128, "not-a-vdf-solution")); -} - -#[test] -fn vdf_rounds_retarget_toward_one_minute_blocks() { - 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, 100); - - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); - let block1 = ledger - .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS) - .unwrap(); - assert_eq!(block1.vdf_rounds, 100); - ledger.apply_block(block1).unwrap(); - assert_eq!(ledger.vdf_rounds(), 100); - - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); - let block2 = ledger - .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(); - assert_eq!(ledger.vdf_rounds(), 110); - - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); - let block3 = ledger - .mine_next_block( - &wallet, - VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2 + VDF_TARGET_BLOCK_MS * 2, - ) - .unwrap(); - assert_eq!(block3.vdf_rounds, 110); - ledger.apply_block(block3).unwrap(); - assert_eq!(ledger.vdf_rounds(), 99); -} - -#[test] -fn future_nonce_transactions_wait_for_missing_gap() { - 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 tx3 = wallet.burn(3, 3); - ledger.submit_transaction(tx3.clone()).unwrap(); - assert_eq!(ledger.next_nonce(wallet.address()), 1); - assert_eq!(ledger.pending().len(), 1); - - ledger.submit_transaction(wallet.burn(1, 1)).unwrap(); - assert_eq!(ledger.next_nonce(wallet.address()), 2); - ledger.submit_transaction(wallet.burn(2, 2)).unwrap(); - assert_eq!(ledger.next_nonce(wallet.address()), 4); - - let block = ledger - .prepare_next_block(wallet.address(), 1) - .unwrap() - .finish(&wallet, "test-vdf".to_string()); - assert_eq!(block.transactions.len(), 3); - assert!(block.transactions.contains(&tx3)); -} - -#[test] -fn in_memory_network_syncs_nodes_without_tcp() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - - let mut network = InMemoryNetwork::default(); - network.insert("alice", node("alice", alice.clone(), allocations.clone())); - network.insert("bob", node("bob", bob.clone(), allocations)); - - network.node_mut("alice").unwrap().burn(10).unwrap(); - network.deliver_until_idle().unwrap(); - assert_eq!(network.node("bob").unwrap().ledger().pending().len(), 1); - - network.node_mut("alice").unwrap().mine_one().unwrap(); - network.deliver_until_idle().unwrap(); - - let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash; - let bob_tip = network.node("bob").unwrap().ledger().status().tip_hash; - assert_eq!(alice_tip, bob_tip); - assert_eq!(network.node("bob").unwrap().ledger().chain().len(), 2); -} - -#[test] -fn in_memory_network_delivers_transaction_to_multiple_peers() { - let wallets = wallets(&["alice", "bob", "carol", "dave"]); - let allocations = allocations(&wallets, 1_000); - let mut network = InMemoryNetwork::default(); - - for (name, wallet) in ["alice", "bob", "carol", "dave"] - .iter() - .zip(wallets.clone()) - { - network.insert(*name, node(name, wallet, allocations.clone())); - } - - network.node_mut("alice").unwrap().burn(15).unwrap(); - network.deliver_until_idle().unwrap(); - - for name in ["bob", "carol", "dave"] { - let pending = network.node(name).unwrap().ledger().pending(); - assert_eq!(pending.len(), 1, "{name} did not receive alice's burn"); - assert_eq!(pending[0].amount(), 15); - } -} - -#[test] -fn in_memory_network_syncs_mined_block_to_multiple_peers() { - let wallets = wallets(&["alice", "bob", "carol", "dave"]); - let allocations = allocations(&wallets, 1_000); - let mut network = InMemoryNetwork::default(); - - for (name, wallet) in ["alice", "bob", "carol", "dave"] - .iter() - .zip(wallets.clone()) - { - network.insert(*name, node(name, wallet, allocations.clone())); - } - - network.node_mut("alice").unwrap().burn(10).unwrap(); - network.deliver_until_idle().unwrap(); - network.node_mut("alice").unwrap().mine_one().unwrap(); - network.deliver_until_idle().unwrap(); - - let tip = network.node("alice").unwrap().ledger().status().tip_hash; - for name in ["alice", "bob", "carol", "dave"] { - let ledger = network.node(name).unwrap().ledger(); - assert_eq!(ledger.status().height, 1, "{name} is at the wrong height"); - assert_eq!(ledger.status().tip_hash, tip, "{name} has a different tip"); - assert!( - ledger.pending().is_empty(), - "{name} kept mined transactions" - ); - } -} - -#[test] -fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob]; - let allocations = allocations(&wallets, 1_000); - let mut network = InMemoryNetwork::default(); - - network.insert("alice", node("alice", alice.clone(), allocations.clone())); - network.insert("bob", node("bob", wallets[1].clone(), allocations)); - - for height in 1..=5 { - network.node_mut("alice").unwrap().burn(1).unwrap(); - network - .node_mut("alice") - .unwrap() - .mine_one_at(height * VDF_TARGET_BLOCK_MS) - .unwrap(); - } - assert_eq!(network.node("alice").unwrap().ledger().status().height, 5); - assert_eq!(network.node("bob").unwrap().ledger().status().height, 0); - - assert!(network.sync_node_from_peer("alice", "bob", 2).unwrap()); - assert_eq!(network.node("bob").unwrap().ledger().status().height, 2); - - assert!(network.sync_node_from_peer("alice", "bob", 10).unwrap()); - let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash; - let bob_status = network.node("bob").unwrap().ledger().status(); - assert_eq!(bob_status.height, 5); - assert_eq!(bob_status.tip_hash, alice_tip); -} - -#[test] -fn joined_nodes_import_transfer_block_and_every_wallet_mines() { - let alice = Wallet::from_seed("flow-alice"); - let bob = Wallet::from_seed("flow-bob"); - let carol = Wallet::from_seed("flow-carol"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 100); - let alice_ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5) - .unwrap(); - - let mut network = InMemoryNetwork::default(); - network.insert( - "a", - NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK), - ); - let mut mined_by = Vec::new(); - - for height in 1..=2 { - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); - mined_by.push(block.miner.clone()); - network.deliver_until_idle().unwrap(); - } - - let bob_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap(); - network.insert( - "b", - NodeCore::from_ledger(bob.clone(), bob_ledger, DEFAULT_BURN_PER_BLOCK), - ); - - for height in 3..=4 { - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); - mined_by.push(block.miner.clone()); - network.deliver_until_idle().unwrap(); - } - - let carol_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap(); - network.insert( - "c", - NodeCore::from_ledger(carol.clone(), carol_ledger, DEFAULT_BURN_PER_BLOCK), - ); - - network - .node_mut("a") - .unwrap() - .transfer(bob.address(), 30) - .unwrap(); - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block5 = network.node_mut("a").unwrap().mine_one_at(5).unwrap(); - assert!( - block5 - .transactions - .iter() - .any(|tx| matches!(tx, mivora::domain::Transaction::Transfer { to, amount, .. } if to == bob.address() && *amount == 30)) - ); - mined_by.push(block5.miner.clone()); - let block5_outbox = network.node_mut("a").unwrap().drain_outbox(); - for envelope in &block5_outbox { - network - .node_mut("b") - .unwrap() - .receive(envelope.clone()) - .unwrap(); - } - assert_eq!(network.node("b").unwrap().ledger().status().height, 5); - assert_eq!(network.node("c").unwrap().ledger().status().height, 4); - let catchup_snapshot = network.node("a").unwrap().chain_snapshot(); - network - .node_mut("c") - .unwrap() - .import_chain_snapshot(catchup_snapshot) - .unwrap(); - - for id in ["a", "b", "c"] { - assert_eq!( - network.node(id).unwrap().ledger().status().height, - 5, - "{id} did not import block 5" - ); - assert_eq!( - network.node(id).unwrap().ledger().balance_of(bob.address()), - 30, - "{id} did not apply A -> B transfer" - ); - } - - network.node_mut("b").unwrap().burn(10).unwrap(); - network.deliver_until_idle().unwrap(); - let block6 = network.node_mut("a").unwrap().mine_one_at(6).unwrap(); - mined_by.push(block6.miner.clone()); - network.deliver_until_idle().unwrap(); - - assert_eq!( - network - .node("a") - .unwrap() - .ledger() - .expected_leader_for_next_block(), - Some(bob.address().to_string()) - ); - network - .node_mut("b") - .unwrap() - .transfer(carol.address(), 10) - .unwrap(); - network.node_mut("b").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block7 = network.node_mut("b").unwrap().mine_one_at(7).unwrap(); - mined_by.push(block7.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("c").unwrap().burn(5).unwrap(); - network.deliver_until_idle().unwrap(); - let block8 = network.node_mut("b").unwrap().mine_one_at(8).unwrap(); - mined_by.push(block8.miner.clone()); - network.deliver_until_idle().unwrap(); - - assert_eq!( - network - .node("a") - .unwrap() - .ledger() - .expected_leader_for_next_block(), - Some(carol.address().to_string()) - ); - network.node_mut("c").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block9 = network.node_mut("c").unwrap().mine_one_at(9).unwrap(); - mined_by.push(block9.miner.clone()); - network.deliver_until_idle().unwrap(); - - let final_tip = network.node("a").unwrap().ledger().status().tip_hash; - for id in ["a", "b", "c"] { - assert_eq!(network.node(id).unwrap().ledger().status().height, 9); - assert_eq!( - network.node(id).unwrap().ledger().status().tip_hash, - final_tip - ); - } - for wallet in [&alice, &bob, &carol] { - assert!( - mined_by.iter().any(|miner| miner == wallet.address()), - "{} never mined", - wallet.address() - ); - } -} - -#[test] -fn persisted_joined_nodes_restart_and_keep_syncing_without_tcp() { - let temp = tempdir().unwrap(); - let alice = Wallet::from_seed("persistent-flow-alice"); - let bob = Wallet::from_seed("persistent-flow-bob"); - let carol = Wallet::from_seed("persistent-flow-carol"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 50); - let alice_ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5) - .unwrap(); - - let mut network = InMemoryNetwork::default(); - network.insert( - "a", - NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK), - ); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.node_mut("a").unwrap().mine_one_at(1).unwrap(); - network.deliver_until_idle().unwrap(); - - let bob_store = SqliteChainStore::open(temp.path().join("bob.sqlite3")).unwrap(); - bob_store - .save(&network.node("a").unwrap().chain_snapshot()) - .unwrap(); - let bob_joined_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap(); - network.insert( - "b", - NodeCore::from_ledger(bob.clone(), bob_joined_ledger, DEFAULT_BURN_PER_BLOCK), - ); - assert_eq!( - network.node("b").unwrap().ledger().status().tip_hash, - network.node("a").unwrap().ledger().status().tip_hash - ); - - network - .node_mut("a") - .unwrap() - .transfer(bob.address(), 10) - .unwrap(); - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().mine_one_at(2).unwrap(); - network.deliver_until_idle().unwrap(); - assert_eq!( - network - .node("b") - .unwrap() - .ledger() - .balance_of(bob.address()), - 10 - ); - - bob_store - .save(&network.node("b").unwrap().chain_snapshot()) - .unwrap(); - let bob_restarted_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap(); - network.insert( - "b", - NodeCore::from_ledger(bob.clone(), bob_restarted_ledger, DEFAULT_BURN_PER_BLOCK), - ); - assert_eq!( - network.node("b").unwrap().ledger().status().tip_hash, - network.node("a").unwrap().ledger().status().tip_hash, - "restarted Bob should resume the persisted chain tip" - ); - - let carol_store = SqliteChainStore::open(temp.path().join("carol.sqlite3")).unwrap(); - carol_store - .save(&network.node("a").unwrap().chain_snapshot()) - .unwrap(); - let carol_joined_ledger = Ledger::from_snapshot(carol_store.load().unwrap().unwrap()).unwrap(); - network.insert( - "c", - NodeCore::from_ledger(carol, carol_joined_ledger, DEFAULT_BURN_PER_BLOCK), - ); - - network.node_mut("b").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().mine_one_at(3).unwrap(); - network.deliver_until_idle().unwrap(); - assert_eq!( - network - .node("a") - .unwrap() - .ledger() - .expected_leader_for_next_block() - .as_deref(), - Some(bob.address()) - ); - - network.node_mut("b").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let bob_block = network.node_mut("b").unwrap().mine_one_at(4).unwrap(); - assert_eq!(bob_block.miner, bob.address()); - network.deliver_until_idle().unwrap(); - - let final_status = network.node("a").unwrap().ledger().status(); - for id in ["b", "c"] { - assert_eq!( - network.node(id).unwrap().ledger().status().height, - final_status.height, - "{id} did not catch up after Bob restarted" - ); - assert_eq!( - network.node(id).unwrap().ledger().status().tip_hash, - final_status.tip_hash, - "{id} ended on a different tip after Bob restarted" - ); - } - - bob_store - .save(&network.node("b").unwrap().chain_snapshot()) - .unwrap(); - assert_eq!( - bob_store - .load() - .unwrap() - .unwrap() - .blocks - .last() - .unwrap() - .height, - final_status.height - ); -} - -#[test] -fn mined_block_gossip_does_not_include_full_chain_snapshot() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let allocations = allocations(&wallets, 1_000); - - let mut alice_node = NodeCore::new(NodeConfig { - wallet: alice, - genesis_allocations: allocations.clone(), - vdf_rounds: 10, - burn_per_block: 1, - }); - - let plan = alice_node.prepare_automatic_mining(1); - let burn_outbox = alice_node.drain_outbox(); - assert_eq!(burn_outbox.len(), 1); - assert!(matches!( - burn_outbox[0], - mivora::app::GossipEnvelope::Transaction(_) - )); - - let work = plan.work.unwrap(); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - alice_node - .complete_prepared_block(work, vdf_output) - .unwrap(); - let block_outbox = alice_node.drain_outbox(); - - assert_eq!(block_outbox.len(), 1); - assert!(matches!( - block_outbox[0], - mivora::app::GossipEnvelope::Block(_) - )); -} - -#[test] -fn received_transaction_is_rebroadcast_to_other_peers_without_networking() { - let names = ["alice", "bob", "carol"]; - let wallets = wallets(&names); - let allocations = allocations(&wallets, 1_000); - let alice = wallets[0].clone(); - let bob = wallets[1].clone(); - let carol = wallets[2].clone(); - - let mut carol_node = node("carol", carol, allocations.clone()); - let mut hub = node("alice", alice, allocations.clone()); - let mut bob_node = node("bob", bob, allocations); - - let tx = carol_node.burn(25).unwrap(); - carol_node.drain_outbox(); - - hub.receive(mivora::app::GossipEnvelope::Transaction(tx.clone())) - .unwrap(); - let forwarded = hub.drain_outbox(); - assert_eq!(forwarded.len(), 1); - assert!(matches!( - forwarded[0], - mivora::app::GossipEnvelope::Transaction(_) - )); - - for envelope in forwarded { - bob_node.receive(envelope).unwrap(); - } - assert!( - bob_node - .ledger() - .pending() - .iter() - .any(|pending| pending.signature() == tx.signature()) - ); - - hub.receive(mivora::app::GossipEnvelope::Transaction(tx)) - .unwrap(); - assert!(hub.drain_outbox().is_empty()); -} - -#[test] -fn mempool_gossip_repairs_future_nonce_gap_without_networking() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let allocations = allocations(&wallets, 1_000); - let mut alice_node = node("alice", alice, allocations.clone()); - let mut bob_node = node("bob", bob, allocations); - - let first = alice_node.burn(1).unwrap(); - let second = alice_node.burn(1).unwrap(); - alice_node.drain_outbox(); - - bob_node - .receive(mivora::app::GossipEnvelope::Transaction(second.clone())) - .unwrap(); - assert_eq!(bob_node.ledger().pending().len(), 1); - - let mut requests = Vec::new(); - for envelope in alice_node.mempool_gossip() { - match envelope { - mivora::app::GossipEnvelope::Inventory { txs, blocks } => { - requests.extend(bob_node.missing_inventory_requests(&txs, &blocks)); - } - other => bob_node.receive(other).unwrap(), - } - } - for request in requests { - match request { - mivora::app::GossipEnvelope::TransactionRequest { signatures } => { - bob_node - .receive(mivora::app::GossipEnvelope::Transactions { - transactions: alice_node.transactions_by_signature(&signatures), - }) - .unwrap(); - } - other => bob_node.receive(other).unwrap(), - } - } - let block = alice_node.mine_one_at(1).unwrap(); - let signatures = block - .transactions - .iter() - .map(|tx| tx.signature()) - .collect::<Vec<_>>(); - - assert!(signatures.contains(&first.signature())); - assert!(signatures.contains(&second.signature())); -} - -#[test] -fn received_block_is_rebroadcast_to_other_peers_without_networking() { - let names = ["alice", "bob", "carol"]; - let wallets = wallets(&names); - let allocations = allocations(&wallets, 1_000); - let alice = wallets[0].clone(); - let bob = wallets[1].clone(); - let carol = wallets[2].clone(); - - let mut miner = node("alice", alice, allocations.clone()); - let mut hub = node("bob", bob, allocations.clone()); - let mut carol_node = node("carol", carol, allocations); - - miner.burn(10).unwrap(); - miner.drain_outbox(); - let block = miner.mine_one_at(1).unwrap(); - miner.drain_outbox(); - - hub.receive(mivora::app::GossipEnvelope::Block(block.clone())) - .unwrap(); - let forwarded = hub.drain_outbox(); - assert_eq!(forwarded.len(), 1); - assert!(matches!( - forwarded[0], - mivora::app::GossipEnvelope::Block(_) - )); - - for envelope in forwarded { - carol_node.receive(envelope).unwrap(); - } - assert_eq!(carol_node.ledger().height(), 1); - assert_eq!( - carol_node.ledger().status().tip_hash, - miner.ledger().status().tip_hash - ); - - hub.receive(mivora::app::GossipEnvelope::Block(block)) - .unwrap(); - assert!(hub.drain_outbox().is_empty()); -} - -#[test] -fn imported_snapshot_blocks_are_rebroadcast_without_networking() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let allocations = allocations(&wallets, 1_000); - let mut miner = node("alice", alice, allocations.clone()); - let mut hub = node("bob", bob, allocations); - - miner.burn(1).unwrap(); - miner.drain_outbox(); - miner.mine_one_at(1).unwrap(); - miner.drain_outbox(); - - miner.burn(1).unwrap(); - miner.drain_outbox(); - miner.mine_one_at(2).unwrap(); - miner.drain_outbox(); - - hub.import_chain_snapshot(miner.chain_snapshot()).unwrap(); - let outbox = hub.drain_outbox(); - assert_eq!(outbox.len(), 1); - match &outbox[0] { - mivora::app::GossipEnvelope::Blocks { blocks } => { - assert_eq!(blocks.len(), 2); - assert_eq!(blocks[0].height, 1); - assert_eq!(blocks[1].height, 2); - } - other => panic!("expected imported blocks gossip, got {other:?}"), - } -} - -#[test] -fn multiple_peers_can_contribute_burns_to_the_same_lottery_block() { - let names = ["alice", "bob", "carol", "dave"]; - let wallets = wallets(&names); - let allocations = allocations(&wallets, 1_000); - let mut network = InMemoryNetwork::default(); - - for (name, wallet) in names.iter().zip(wallets.clone()) { - network.insert(*name, node(name, wallet, allocations.clone())); - } - - for (name, amount) in names.iter().zip([10, 20, 30, 40]) { - network.node_mut(name).unwrap().burn(amount).unwrap(); - } - network.deliver_until_idle().unwrap(); - network.node_mut("alice").unwrap().mine_one().unwrap(); - network.deliver_until_idle().unwrap(); - - for name in names { - let ledger = network.node(name).unwrap().ledger(); - let block = &ledger.chain()[1]; - let burned = block - .transactions - .iter() - .filter(|tx| tx.is_burn()) - .map(|tx| tx.amount()) - .sum::<Amount>(); - - assert_eq!(block.transactions.len(), 4); - assert_eq!(burned, 100); - assert!(ledger.expected_leader_for_next_block().is_some()); - } -} - -#[test] -fn peer_book_tracks_multiple_peers_without_networking() { - let mut peers = PeerBook::from_addresses(vec![ - "127.0.0.1:9444".to_string(), - "127.0.0.1:9445".to_string(), - "127.0.0.1:9444".to_string(), - ]); - - peers.record_sent("127.0.0.1:9444", 2); - peers.record_status("127.0.0.1:9444", 12, "tip-hash".to_string()); - peers.record_error("127.0.0.1:9445", "connection refused"); - peers.record_received("127.0.0.1:9555", 1); - peers.record_inbound_error("127.0.0.1:56666", "invalid nonce"); - - let mut list = peers.list(); - list.sort_by(|left, right| left.address.cmp(&right.address)); - assert_eq!(list.len(), 4); - - let outbound_addresses = peers.addresses(); - assert_eq!(outbound_addresses.len(), 2); - assert!(outbound_addresses.contains(&"127.0.0.1:9444".to_string())); - assert!(outbound_addresses.contains(&"127.0.0.1:9445".to_string())); - assert!(!outbound_addresses.contains(&"127.0.0.1:56666".to_string())); - - let sent_peer = list - .iter() - .find(|peer| peer.address == "127.0.0.1:9444") - .unwrap(); - assert_eq!(sent_peer.messages_sent, 2); - assert_eq!(sent_peer.last_known_height, Some(12)); - assert_eq!(sent_peer.last_known_tip_hash.as_deref(), Some("tip-hash")); - assert_eq!(sent_peer.last_error, None); - - let failed_peer = list - .iter() - .find(|peer| peer.address == "127.0.0.1:9445") - .unwrap(); - assert_eq!( - failed_peer.last_error.as_deref(), - Some("connection refused") - ); - - let inbound_peer = list - .iter() - .find(|peer| peer.address == "127.0.0.1:9555") - .unwrap(); - assert_eq!(inbound_peer.direction, PeerDirection::Inbound); - assert_eq!(inbound_peer.messages_received, 1); - - let inbound_error = list - .iter() - .find(|peer| peer.address == "127.0.0.1:56666") - .unwrap(); - assert_eq!(inbound_error.direction, PeerDirection::Inbound); - assert_eq!(inbound_error.last_error.as_deref(), Some("invalid nonce")); -} - -#[test] -fn chain_snapshot_round_trips_ledger_state() { - 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); - ledger - .submit_transaction(alice.burn(10, ledger.next_nonce(alice.address()))) - .unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(block).unwrap(); - - let restored = Ledger::from_snapshot(ledger.snapshot()).unwrap(); - assert_eq!(restored.status().height, ledger.status().height); - assert_eq!(restored.status().tip_hash, ledger.status().tip_hash); - assert_eq!( - restored.balance_of(alice.address()), - ledger.balance_of(alice.address()) - ); -} - -#[test] -fn friend_node_can_join_snapshot_from_started_chain() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - - let mut alice_genesis = BTreeMap::new(); - alice_genesis.insert(alice.address().to_string(), 1_000); - let mut alice_node = node("alice", alice.clone(), alice_genesis); - alice_node.burn(1).unwrap(); - alice_node.automatic_mine_once(1); - - let joined_ledger = Ledger::from_snapshot(alice_node.chain_snapshot()).unwrap(); - let mut bob_node = NodeCore::from_ledger(bob.clone(), joined_ledger, DEFAULT_BURN_PER_BLOCK); - - assert_eq!( - bob_node.ledger().status().tip_hash, - alice_node.ledger().status().tip_hash - ); - assert_eq!(bob_node.ledger().status().height, 1); - assert_eq!(bob_node.ledger().balance_of(bob.address()), 0); - - let outcome = bob_node.automatic_mine_once(2); - assert!(outcome.burned.is_none()); -} - -#[test] -fn running_node_rejects_snapshot_from_different_genesis() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - - let mut alice_genesis = BTreeMap::new(); - alice_genesis.insert(alice.address().to_string(), 1_000); - let alice_node = node("alice", alice, alice_genesis); - - let mut bob_genesis = BTreeMap::new(); - bob_genesis.insert(bob.address().to_string(), 1_000); - let mut bob_node = node("bob", bob, bob_genesis); - - let error = bob_node - .import_chain_snapshot(alice_node.chain_snapshot()) - .unwrap_err(); - - assert!(error.to_string().contains("genesis")); -} - -#[test] -fn same_height_fork_snapshot_does_not_reorg() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let shared_genesis = allocations(&wallets, 1_000); - 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, &alice, &local_first_fork_hash, 1).unwrap(); - - let local_tip = local.status().tip_hash; - assert!(!local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert_eq!(local.status().tip_hash, local_tip); -} - -#[test] -fn fork_choice_preflight_rejects_non_matching_genesis_before_scoring() { - let alice = Wallet::from_seed("preflight-genesis-alice"); - let bob = Wallet::from_seed("preflight-genesis-bob"); - let mut local_genesis = BTreeMap::new(); - local_genesis.insert(alice.address().to_string(), 1_000); - let mut remote_genesis = BTreeMap::new(); - remote_genesis.insert(bob.address().to_string(), 1_000); - let mut local = Ledger::new(local_genesis, 1); - let mut remote = Ledger::new(remote_genesis, 1); - - mine_wallet_burn_block(&mut local, &alice, 1); - for timestamp in 1..=3 { - mine_wallet_burn_block(&mut remote, &bob, timestamp); - } - - let local_tip = local.status().tip_hash; - let error = local.extend_from_snapshot(remote.snapshot()).unwrap_err(); - - assert!(error.to_string().contains("genesis")); - assert_eq!(local.status().tip_hash, local_tip); -} - -#[test] -fn fork_choice_preflight_rejects_invalid_fork_before_vrf_scoring() { - let alice = Wallet::from_seed("preflight-invalid-alice"); - let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); - let mut common = Ledger::new(shared_genesis, 1); - for timestamp in 1..=5 { - mine_wallet_burn_block(&mut common, &alice, timestamp); - } - - let mut local = common.clone(); - let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); - mine_wallet_burn_block(&mut local, &alice, 7); - mine_wallet_burn_block(&mut local, &alice, 8); - - let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); - assert!(remote.chain()[6].hash < local.chain()[6].hash); - let mut invalid_snapshot = remote.snapshot(); - if let Some(transaction) = invalid_snapshot.blocks[6].transactions.first_mut() { - match transaction { - mivora::domain::Transaction::Burn { signature, .. } - | mivora::domain::Transaction::Transfer { signature, .. } => signature.push_str("00"), - } - } - - let local_tip = local.status().tip_hash; - let error = local.extend_from_snapshot(invalid_snapshot).unwrap_err(); - - assert!(error.to_string().contains("invalid")); - assert_eq!(local.status().height, 8); - assert_eq!(local.status().tip_hash, local_tip); -} - -#[test] -fn fork_conflict_before_last_six_blocks_is_finalized_even_if_remote_is_longer() { - let alice = Wallet::from_seed("finality-alice"); - let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); - let mut common = Ledger::new(shared_genesis, 1); - mine_wallet_burn_block(&mut common, &alice, 1); - - let mut local = common.clone(); - for timestamp in 2..=8 { - mine_wallet_burn_block(&mut local, &alice, timestamp); - } - - let mut remote = common; - for timestamp in 20..=29 { - mine_wallet_burn_block(&mut remote, &alice, timestamp); - } - - assert_eq!(local.status().height, 8); - assert_eq!(remote.status().height, 11); - let finalized_local_tip = local.status().tip_hash; - - assert!( - !local.extend_from_snapshot(remote.snapshot()).unwrap(), - "forks that rewrite blocks before the last six should not be accepted" - ); - assert_eq!(local.status().height, 8); - assert_eq!(local.status().tip_hash, finalized_local_tip); -} - -#[test] -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); - for timestamp in 1..=5 { - mine_wallet_burn_block(&mut common, &alice, timestamp); - } - - let mut local = common.clone(); - let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); - mine_wallet_burn_block(&mut local, &alice, 7); - mine_wallet_burn_block(&mut local, &alice, 8); - - let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); - - assert_eq!(local.status().height, 8); - assert_eq!(remote.status().height, 6); - assert!(remote.status().height + 2 >= local.status().height); - assert!( - remote.chain()[6].hash < local.chain()[6].hash, - "test setup should give the remote fork the better VRF leader score" - ); - let remote_tip = remote.status().tip_hash; - - assert!( - !local.extend_from_snapshot(remote.snapshot()).unwrap(), - "a shorter fork should not beat greater positive chain quality" - ); - assert_eq!(local.status().height, 8); - assert_ne!(local.status().tip_hash, remote_tip); -} - -#[test] -fn better_vrf_fork_inside_last_six_loses_when_more_than_two_blocks_shorter() { - let alice = Wallet::from_seed("too-short-vrf-alice"); - let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); - let mut common = Ledger::new(shared_genesis, 1); - for timestamp in 1..=5 { - mine_wallet_burn_block(&mut common, &alice, timestamp); - } - - let mut local = common.clone(); - let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); - mine_wallet_burn_block(&mut local, &alice, 7); - mine_wallet_burn_block(&mut local, &alice, 8); - mine_wallet_burn_block(&mut local, &alice, 9); - - let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); - - assert_eq!(local.status().height, 9); - assert_eq!(remote.status().height, 6); - assert!(remote.chain()[6].hash < local.chain()[6].hash); - let local_tip = local.status().tip_hash; - - assert!( - !local.extend_from_snapshot(remote.snapshot()).unwrap(), - "even a better VRF fork should not win when more than two blocks shorter" - ); - assert_eq!(local.status().height, 9); - assert_eq!(local.status().tip_hash, local_tip); -} - -#[test] -fn transactions_from_abandoned_fork_blocks_return_to_mempool_after_switch() { - let alice = Wallet::from_seed("reorg-alice"); - let bob = Wallet::from_seed("reorg-bob"); - 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_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(); - let abandoned_transfer = bob.transfer(carol.address(), 7, local.next_nonce(bob.address())); - local - .submit_transaction(abandoned_transfer.clone()) - .unwrap(); - mine_wallet_burn_block(&mut local, &alice, 2); - - let mut remote = common; - for timestamp in 20..=23 { - mine_wallet_burn_block(&mut remote, &alice, timestamp); - } - - assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert!( - local - .pending() - .iter() - .any(|tx| tx.signature() == abandoned_transfer.signature()), - "transactions mined only on the abandoned fork should return to the mempool" - ); -} - -#[test] -fn longer_valid_fork_snapshot_reorgs_and_preserves_local_transactions() { - let alice = Wallet::from_seed("alice"); - 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_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, 1).unwrap(); - local.apply_block(local_block).unwrap(); - let local_transfer = bob.transfer(alice.address(), 5, local.next_nonce(bob.address())); - local.submit_transaction(local_transfer.clone()).unwrap(); - - remote - .submit_transaction(alice.burn(1, remote.next_nonce(alice.address()))) - .unwrap(); - let remote_block_1 = remote.mine_next_block(&alice, 1).unwrap(); - remote.apply_block(remote_block_1).unwrap(); - remote - .submit_transaction(alice.burn(1, remote.next_nonce(alice.address()))) - .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; - assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert_eq!(local.status().height, 2); - assert_eq!(local.status().tip_hash, remote_tip); - assert!( - local - .pending() - .iter() - .any(|tx| tx.signature() == local_transfer.signature()) - ); -} - -#[test] -fn node_receives_chain_snapshot_envelope_when_joining_without_tcp() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - - let wallets = vec![alice.clone(), bob.clone()]; - let shared_genesis = allocations(&wallets, 1_000); - let mut alice_node = node("alice", alice, shared_genesis.clone()); - alice_node.burn(1).unwrap(); - alice_node.mine_one().unwrap(); - - let mut bob_node = node("bob", bob, shared_genesis); - - bob_node - .receive(mivora::app::GossipEnvelope::ChainSnapshot( - alice_node.chain_snapshot(), - )) - .unwrap(); - - assert_eq!( - bob_node.ledger().status().tip_hash, - alice_node.ledger().status().tip_hash - ); -} diff --git a/tests/luun.rs b/tests/luun.rs @@ -0,0 +1,1725 @@ +use std::collections::BTreeMap; + +use luun::{ + adapters::chain_store::SqliteChainStore, + app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection}, + domain::{ + Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, VDF_TARGET_BLOCK_MS, Wallet, + run_vdf, verify_vdf, + }, +}; +use tempfile::tempdir; + +fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { + NodeCore::new(NodeConfig { + wallet, + genesis_allocations: allocations, + vdf_rounds: 25, + burn_per_block: DEFAULT_BURN_PER_BLOCK, + }) +} + +fn wallets(names: &[&str]) -> Vec<Wallet> { + names.iter().map(|name| Wallet::from_seed(name)).collect() +} + +fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> { + wallets + .iter() + .map(|wallet| (wallet.address().to_string(), amount)) + .collect() +} + +fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) -> String { + ledger + .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) + .unwrap(); + let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap(); + let hash = block.hash.clone(); + ledger.apply_block(block).unwrap(); + hash +} + +fn fork_with_better_vrf_block( + base: &Ledger, + wallet: &Wallet, + local_fork_block_hash: &str, + first_timestamp_ms: u64, +) -> Option<Ledger> { + for offset in 0..10_000 { + let mut candidate = base.clone(); + let hash = mine_wallet_burn_block(&mut candidate, wallet, first_timestamp_ms + offset); + if hash.as_str() < local_fork_block_hash { + return Some(candidate); + } + } + None +} + +fn fork_with_worse_vrf_block( + base: &Ledger, + wallet: &Wallet, + local_fork_block_hash: &str, + first_timestamp_ms: u64, +) -> Option<Ledger> { + for offset in 0..10_000 { + let mut candidate = base.clone(); + let hash = mine_wallet_burn_block(&mut candidate, wallet, first_timestamp_ms + offset); + if hash.as_str() > local_fork_block_hash { + return Some(candidate); + } + } + None +} + +fn starter_node(wallet: Wallet) -> NodeCore { + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 1); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25) + .unwrap(); + NodeCore::from_ledger(wallet, ledger, DEFAULT_BURN_PER_BLOCK) +} + +#[test] +fn genesis_burn_starts_chain_with_reward_and_first_leader() { + let alice = Wallet::from_seed("alice"); + let node = starter_node(alice.clone()); + + let genesis = &node.ledger().chain()[0]; + assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD); + assert_eq!(genesis.height, 0); + assert_eq!(genesis.miner, alice.address()); + assert_eq!(genesis.reward, BLOCK_REWARD); + assert_eq!(genesis.transactions.len(), 1); + assert!(genesis.transactions[0].is_burn()); + assert_eq!(genesis.transactions[0].amount(), 1); + assert_eq!( + node.ledger().expected_leader_for_next_block().as_deref(), + Some(alice.address()) + ); +} + +#[test] +fn starter_node_waits_for_a_burn_before_vdf_work() { + let alice = Wallet::from_seed("alice"); + let mut node = starter_node(alice.clone()); + + let outcome = node.automatic_mine_once(1); + assert!(outcome.burned.is_none()); + assert!(outcome.block.is_none()); + assert!( + outcome + .skipped_reason + .as_deref() + .is_some_and(|reason| reason.contains("at least one burn")) + ); + assert_eq!(node.ledger().status().height, 0); + assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD); +} + +#[test] +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(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 1_000); + + let mut ledger = Ledger::new(allocations, 10); + ledger + .submit_transaction(alice.burn(20, ledger.next_nonce(alice.address()))) + .unwrap(); + ledger + .submit_transaction(bob.burn(80, ledger.next_nonce(bob.address()))) + .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 + } else { + &alice + }; + assert!(ledger.mine_next_block(non_leader, 2).is_err()); + + let leader_wallet = if expected == alice.address() { + &alice + } else { + &bob + }; + ledger + .submit_transaction(leader_wallet.burn(1, ledger.next_nonce(leader_wallet.address()))) + .unwrap(); + assert!(ledger.mine_next_block(leader_wallet, 2).is_ok()); +} + +#[test] +fn transfer_and_burn_update_balances_when_block_is_applied() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 100); + + let mut ledger = Ledger::new(allocations, 10); + ledger + .submit_transaction(alice.transfer(bob.address(), 125, ledger.next_nonce(alice.address()))) + .unwrap(); + ledger + .submit_transaction(alice.burn(25, ledger.next_nonce(alice.address()))) + .unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + ledger.apply_block(block).unwrap(); + + assert_eq!(ledger.balance_of(alice.address()), 950); + assert_eq!(ledger.balance_of(bob.address()), 225); +} + +#[test] +fn forged_transaction_is_rejected() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 1_000); + let mut ledger = Ledger::new(allocations, 10); + + let mut forged = bob.burn(10, 1); + if let luun::domain::Transaction::Burn { from, .. } = &mut forged { + *from = alice.address().to_string(); + } + + let error = ledger.submit_transaction(forged).unwrap_err(); + assert!(error.to_string().contains("signature")); +} + +#[test] +fn block_with_forged_transaction_is_rejected() { + 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); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); + if let luun::domain::Transaction::Burn { signature, .. } = &mut block.transactions[0] { + signature.push_str("00"); + } + 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("signature")); +} + +#[test] +fn block_reward_is_fixed_at_one_hundred_luun() { + 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); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert_eq!(block.reward, BLOCK_REWARD); + + ledger.apply_block(block).unwrap(); + assert_eq!(ledger.balance_of(alice.address()), 1_099); +} + +#[test] +fn transaction_fees_are_paid_to_the_block_miner() { + let alice = Wallet::from_seed("fee-miner-alice"); + let bob = Wallet::from_seed("fee-payer-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1); + allocations.insert(bob.address().to_string(), 200); + + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + ledger + .submit_transaction(bob.transfer_with_fee( + alice.address(), + 10, + 7, + ledger.next_nonce(bob.address()), + )) + .unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert_eq!(block.reward, BLOCK_REWARD + 7); + ledger.apply_block(block).unwrap(); + + assert_eq!(ledger.balance_of(alice.address()), 216); + assert_eq!(ledger.balance_of(bob.address()), 183); +} + +#[test] +fn miner_orders_block_transactions_by_fee_rate_after_required_burn() { + let wallets = wallets(&["fee-order-alice", "fee-order-bob", "fee-order-carol"]); + let alice = &wallets[0]; + let bob = &wallets[1]; + let carol = &wallets[2]; + let mut allocations = allocations(&wallets, 1_000); + allocations.insert(alice.address().to_string(), 1); + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + let required_burn = alice.burn(1, ledger.next_nonce(alice.address())); + let low_fee = bob.transfer_with_fee(alice.address(), 1, 1, ledger.next_nonce(bob.address())); + let high_fee = + carol.transfer_with_fee(alice.address(), 1, 20, ledger.next_nonce(carol.address())); + ledger.submit_transaction(low_fee.clone()).unwrap(); + ledger.submit_transaction(high_fee.clone()).unwrap(); + ledger.submit_transaction(required_burn.clone()).unwrap(); + + let block = ledger.mine_next_block(alice, 1).unwrap(); + let signatures = block + .transactions + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<Vec<_>>(); + + assert_eq!(signatures[0], required_burn.signature()); + assert!( + signatures + .iter() + .position(|signature| signature == high_fee.signature()) + < signatures + .iter() + .position(|signature| signature == low_fee.signature()) + ); +} + +#[test] +fn oversized_blocks_are_rejected() { + let alice = Wallet::from_seed("oversized-block-alice"); + let bob = Wallet::from_seed("oversized-block-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1); + allocations.insert(bob.address().to_string(), 1_000); + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); + let oversized_transfer = bob.transfer_with_fee( + "x".repeat(MAX_BLOCK_BYTES), + 1, + 3, + ledger.next_nonce(bob.address()), + ); + block.transactions.push(oversized_transfer); + block.reward = BLOCK_REWARD + 3; + block.hash = block.compute_hash(); + + let error = ledger.apply_block(block).unwrap_err(); + + assert!(format!("{error:#}").contains("max block size")); +} + +#[test] +fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() { + let wallets = wallets(&[ + "oversized-select-alice", + "oversized-select-bob", + "oversized-select-carol", + ]); + let alice = &wallets[0]; + let bob = &wallets[1]; + let carol = &wallets[2]; + let mut allocations = allocations(&wallets, 200_000); + allocations.insert(alice.address().to_string(), 1); + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let oversized = bob.transfer_with_fee( + "x".repeat(MAX_BLOCK_BYTES), + 1, + 100_000, + ledger.next_nonce(bob.address()), + ); + let fitting = + carol.transfer_with_fee(alice.address(), 1, 5, ledger.next_nonce(carol.address())); + ledger.submit_transaction(oversized.clone()).unwrap(); + ledger.submit_transaction(fitting.clone()).unwrap(); + + let block = ledger.mine_next_block(alice, 1).unwrap(); + let signatures = block + .transactions + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<Vec<_>>(); + + assert!(!signatures.contains(&oversized.signature().to_string())); + assert!(signatures.contains(&fitting.signature().to_string())); + assert!(block.serialized_size_bytes().unwrap() <= MAX_BLOCK_BYTES); +} + +#[test] +fn transfer_that_would_overflow_recipient_balance_is_rejected() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1); + allocations.insert(bob.address().to_string(), Amount::MAX); + + let mut ledger = Ledger::new(allocations, 10); + let error = ledger + .submit_transaction(alice.transfer(bob.address(), 1, ledger.next_nonce(alice.address()))) + .unwrap_err(); + + assert!(format!("{error:#}").contains("balance overflow")); +} + +#[test] +fn block_reward_that_would_overflow_miner_balance_is_rejected() { + let alice = Wallet::from_seed("alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), Amount::MAX); + + let mut ledger = Ledger::new(allocations, 10); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + + let error = ledger.apply_block(block).unwrap_err(); + + assert!(format!("{error:#}").contains("balance overflow")); +} + +#[test] +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 vdf_work_requires_at_least_one_pending_burn() { + let alice = Wallet::from_seed("alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + + let ledger = Ledger::new(allocations, 10); + let error = ledger.prepare_next_block(alice.address(), 1).unwrap_err(); + + assert!(format!("{error:#}").contains("at least one burn")); +} + +#[test] +fn leader_block_without_burn_is_rejected() { + 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); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); + block.transactions.clear(); + block.hash = block.compute_hash(); + + let error = ledger.apply_block(block).unwrap_err(); + assert!(format!("{error:#}").contains("at least one burn")); +} + +#[test] +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); + + let mut ledger = Ledger::new(allocations, 10); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); + block.timestamp_ms += 1; + + let error = ledger.apply_block(block).unwrap_err(); + assert!(error.to_string().contains("block hash is invalid")); +} + +#[test] +fn automatic_mining_burns_configured_amount_once_per_height() { + let alice = Wallet::from_seed("alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + + let mut node = NodeCore::new(NodeConfig { + wallet: alice.clone(), + genesis_allocations: allocations, + vdf_rounds: 10, + burn_per_block: 25, + }); + + let first = node.automatic_mine_once(1); + assert!(first.burned.is_some()); + assert!(first.block.is_some()); + assert_eq!(node.ledger().chain().len(), 2); + assert_eq!(node.ledger().balance_of(alice.address()), 1_075); + + let second = node.automatic_mine_once(2); + assert!(second.burned.is_some()); + assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(25)); +} + +#[test] +fn default_automatic_mining_does_not_burn() { + assert_eq!(DEFAULT_BURN_PER_BLOCK, 0); + + let alice = Wallet::from_seed("alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + let mut node = node("alice", alice.clone(), allocations); + + let outcome = node.automatic_mine_once(1); + assert!(outcome.burned.is_none()); + assert!(outcome.block.is_none()); + assert!( + outcome + .skipped_reason + .as_deref() + .is_some_and(|reason| reason.contains("at least one burn")) + ); + assert_eq!(node.ledger().balance_of(alice.address()), 1_000); +} + +#[test] +fn burn_per_block_can_be_set_to_zero() { + let alice = Wallet::from_seed("alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + let mut node = node("alice", alice, allocations); + + let burned = node.set_burn_per_block(25).unwrap(); + assert!(burned.is_some()); + assert_eq!(node.status().mining.burn_per_block, 25); + let burned = node.set_burn_per_block(0).unwrap(); + assert!(burned.is_none()); + assert_eq!(node.status().mining.burn_per_block, 0); +} + +#[test] +fn automatic_burn_status_shows_configured_fee() { + let alice = Wallet::from_seed("auto-fee-status-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + let mut node = node("alice", alice, allocations); + + assert_eq!(node.status().mining.automatic_burn_fee, 0); + + node.set_burn_per_block(1).unwrap(); + assert_eq!(node.status().mining.burn_per_block, 1); + assert_eq!(node.status().mining.automatic_burn_fee, 0); + + node.set_burn_per_block(50).unwrap(); + assert_eq!(node.status().mining.burn_per_block, 50); + assert_eq!(node.status().mining.automatic_burn_fee, 1); +} + +#[test] +fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 100); + + let mut ledger = Ledger::new(allocations.clone(), 25); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let first = ledger.mine_next_block(&alice, 1).unwrap(); + ledger.apply_block(first).unwrap(); + + let mut bob_node = node("bob", bob.clone(), allocations); + bob_node + .receive(luun::app::GossipEnvelope::ChainSnapshot(ledger.snapshot())) + .unwrap(); + + let skipped = bob_node.automatic_mine_once(2); + assert!(skipped.burned.is_none()); + assert!(skipped.block.is_none()); + + let burned = bob_node.set_burn_per_block(1).unwrap(); + + assert!(burned.is_some()); + assert_eq!(bob_node.ledger().pending().len(), 1); + assert_eq!(bob_node.ledger().pending()[0].sender(), bob.address()); + assert_eq!(bob_node.ledger().pending()[0].amount(), 1); +} + +#[test] +fn automatic_mining_waits_when_wallet_is_not_selected_leader() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 1_000); + + let mut ledger = Ledger::new(allocations.clone(), 25); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let first = ledger.mine_next_block(&alice, 1).unwrap(); + ledger.apply_block(first).unwrap(); + + let mut bob_node = node("bob", bob.clone(), allocations); + bob_node.set_burn_per_block(10).unwrap(); + bob_node + .receive(luun::app::GossipEnvelope::Block(ledger.chain()[1].clone())) + .unwrap(); + + let outcome = bob_node.automatic_mine_once(2); + assert!(outcome.burned.is_some()); + assert!(outcome.block.is_none()); + assert!(outcome.skipped_reason.unwrap().contains(alice.address())); + assert_eq!(bob_node.ledger().chain().len(), 2); +} + +#[test] +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_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, 1).unwrap(); + block.vdf_rounds = 1; + block.hash = block.compute_hash(); + block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds); + + assert!(ledger.apply_block(block).is_err()); +} + +#[test] +fn vdf_solution_verifies_without_rerunning_delay() { + let solution = run_vdf("test-seed", 128); + + assert!(verify_vdf("test-seed", 128, &solution)); + assert!(!verify_vdf("other-seed", 128, &solution)); + assert!(!verify_vdf("test-seed", 129, &solution)); + assert!(!verify_vdf("test-seed", 128, "not-a-vdf-solution")); +} + +#[test] +fn vdf_rounds_retarget_toward_one_minute_blocks() { + 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, 100); + + ledger + .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) + .unwrap(); + let block1 = ledger + .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS) + .unwrap(); + assert_eq!(block1.vdf_rounds, 100); + ledger.apply_block(block1).unwrap(); + assert_eq!(ledger.vdf_rounds(), 100); + + ledger + .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) + .unwrap(); + let block2 = ledger + .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(); + assert_eq!(ledger.vdf_rounds(), 110); + + ledger + .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) + .unwrap(); + let block3 = ledger + .mine_next_block( + &wallet, + VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2 + VDF_TARGET_BLOCK_MS * 2, + ) + .unwrap(); + assert_eq!(block3.vdf_rounds, 110); + ledger.apply_block(block3).unwrap(); + assert_eq!(ledger.vdf_rounds(), 99); +} + +#[test] +fn future_nonce_transactions_wait_for_missing_gap() { + 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 tx3 = wallet.burn(3, 3); + ledger.submit_transaction(tx3.clone()).unwrap(); + assert_eq!(ledger.next_nonce(wallet.address()), 1); + assert_eq!(ledger.pending().len(), 1); + + ledger.submit_transaction(wallet.burn(1, 1)).unwrap(); + assert_eq!(ledger.next_nonce(wallet.address()), 2); + ledger.submit_transaction(wallet.burn(2, 2)).unwrap(); + assert_eq!(ledger.next_nonce(wallet.address()), 4); + + let block = ledger + .prepare_next_block(wallet.address(), 1) + .unwrap() + .finish(&wallet, "test-vdf".to_string()); + assert_eq!(block.transactions.len(), 3); + assert!(block.transactions.contains(&tx3)); +} + +#[test] +fn in_memory_network_syncs_nodes_without_tcp() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 1_000); + + let mut network = InMemoryNetwork::default(); + network.insert("alice", node("alice", alice.clone(), allocations.clone())); + network.insert("bob", node("bob", bob.clone(), allocations)); + + network.node_mut("alice").unwrap().burn(10).unwrap(); + network.deliver_until_idle().unwrap(); + assert_eq!(network.node("bob").unwrap().ledger().pending().len(), 1); + + network.node_mut("alice").unwrap().mine_one().unwrap(); + network.deliver_until_idle().unwrap(); + + let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash; + let bob_tip = network.node("bob").unwrap().ledger().status().tip_hash; + assert_eq!(alice_tip, bob_tip); + assert_eq!(network.node("bob").unwrap().ledger().chain().len(), 2); +} + +#[test] +fn in_memory_network_delivers_transaction_to_multiple_peers() { + let wallets = wallets(&["alice", "bob", "carol", "dave"]); + let allocations = allocations(&wallets, 1_000); + let mut network = InMemoryNetwork::default(); + + for (name, wallet) in ["alice", "bob", "carol", "dave"] + .iter() + .zip(wallets.clone()) + { + network.insert(*name, node(name, wallet, allocations.clone())); + } + + network.node_mut("alice").unwrap().burn(15).unwrap(); + network.deliver_until_idle().unwrap(); + + for name in ["bob", "carol", "dave"] { + let pending = network.node(name).unwrap().ledger().pending(); + assert_eq!(pending.len(), 1, "{name} did not receive alice's burn"); + assert_eq!(pending[0].amount(), 15); + } +} + +#[test] +fn in_memory_network_syncs_mined_block_to_multiple_peers() { + let wallets = wallets(&["alice", "bob", "carol", "dave"]); + let allocations = allocations(&wallets, 1_000); + let mut network = InMemoryNetwork::default(); + + for (name, wallet) in ["alice", "bob", "carol", "dave"] + .iter() + .zip(wallets.clone()) + { + network.insert(*name, node(name, wallet, allocations.clone())); + } + + network.node_mut("alice").unwrap().burn(10).unwrap(); + network.deliver_until_idle().unwrap(); + network.node_mut("alice").unwrap().mine_one().unwrap(); + network.deliver_until_idle().unwrap(); + + let tip = network.node("alice").unwrap().ledger().status().tip_hash; + for name in ["alice", "bob", "carol", "dave"] { + let ledger = network.node(name).unwrap().ledger(); + assert_eq!(ledger.status().height, 1, "{name} is at the wrong height"); + assert_eq!(ledger.status().tip_hash, tip, "{name} has a different tip"); + assert!( + ledger.pending().is_empty(), + "{name} kept mined transactions" + ); + } +} + +#[test] +fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let wallets = vec![alice.clone(), bob]; + let allocations = allocations(&wallets, 1_000); + let mut network = InMemoryNetwork::default(); + + network.insert("alice", node("alice", alice.clone(), allocations.clone())); + network.insert("bob", node("bob", wallets[1].clone(), allocations)); + + for height in 1..=5 { + network.node_mut("alice").unwrap().burn(1).unwrap(); + network + .node_mut("alice") + .unwrap() + .mine_one_at(height * VDF_TARGET_BLOCK_MS) + .unwrap(); + } + assert_eq!(network.node("alice").unwrap().ledger().status().height, 5); + assert_eq!(network.node("bob").unwrap().ledger().status().height, 0); + + assert!(network.sync_node_from_peer("alice", "bob", 2).unwrap()); + assert_eq!(network.node("bob").unwrap().ledger().status().height, 2); + + assert!(network.sync_node_from_peer("alice", "bob", 10).unwrap()); + let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash; + let bob_status = network.node("bob").unwrap().ledger().status(); + assert_eq!(bob_status.height, 5); + assert_eq!(bob_status.tip_hash, alice_tip); +} + +#[test] +fn joined_nodes_import_transfer_block_and_every_wallet_mines() { + let alice = Wallet::from_seed("flow-alice"); + let bob = Wallet::from_seed("flow-bob"); + let carol = Wallet::from_seed("flow-carol"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), 100); + let alice_ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5) + .unwrap(); + + let mut network = InMemoryNetwork::default(); + network.insert( + "a", + NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK), + ); + let mut mined_by = Vec::new(); + + for height in 1..=2 { + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); + mined_by.push(block.miner.clone()); + network.deliver_until_idle().unwrap(); + } + + let bob_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap(); + network.insert( + "b", + NodeCore::from_ledger(bob.clone(), bob_ledger, DEFAULT_BURN_PER_BLOCK), + ); + + for height in 3..=4 { + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); + mined_by.push(block.miner.clone()); + network.deliver_until_idle().unwrap(); + } + + let carol_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap(); + network.insert( + "c", + NodeCore::from_ledger(carol.clone(), carol_ledger, DEFAULT_BURN_PER_BLOCK), + ); + + network + .node_mut("a") + .unwrap() + .transfer(bob.address(), 30) + .unwrap(); + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block5 = network.node_mut("a").unwrap().mine_one_at(5).unwrap(); + assert!( + block5 + .transactions + .iter() + .any(|tx| matches!(tx, luun::domain::Transaction::Transfer { to, amount, .. } if to == bob.address() && *amount == 30)) + ); + mined_by.push(block5.miner.clone()); + let block5_outbox = network.node_mut("a").unwrap().drain_outbox(); + for envelope in &block5_outbox { + network + .node_mut("b") + .unwrap() + .receive(envelope.clone()) + .unwrap(); + } + assert_eq!(network.node("b").unwrap().ledger().status().height, 5); + assert_eq!(network.node("c").unwrap().ledger().status().height, 4); + let catchup_snapshot = network.node("a").unwrap().chain_snapshot(); + network + .node_mut("c") + .unwrap() + .import_chain_snapshot(catchup_snapshot) + .unwrap(); + + for id in ["a", "b", "c"] { + assert_eq!( + network.node(id).unwrap().ledger().status().height, + 5, + "{id} did not import block 5" + ); + assert_eq!( + network.node(id).unwrap().ledger().balance_of(bob.address()), + 30, + "{id} did not apply A -> B transfer" + ); + } + + network.node_mut("b").unwrap().burn(10).unwrap(); + network.deliver_until_idle().unwrap(); + let block6 = network.node_mut("a").unwrap().mine_one_at(6).unwrap(); + mined_by.push(block6.miner.clone()); + network.deliver_until_idle().unwrap(); + + assert_eq!( + network + .node("a") + .unwrap() + .ledger() + .expected_leader_for_next_block(), + Some(bob.address().to_string()) + ); + network + .node_mut("b") + .unwrap() + .transfer(carol.address(), 10) + .unwrap(); + network.node_mut("b").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block7 = network.node_mut("b").unwrap().mine_one_at(7).unwrap(); + mined_by.push(block7.miner.clone()); + network.deliver_until_idle().unwrap(); + + network.node_mut("c").unwrap().burn(5).unwrap(); + network.deliver_until_idle().unwrap(); + let block8 = network.node_mut("b").unwrap().mine_one_at(8).unwrap(); + mined_by.push(block8.miner.clone()); + network.deliver_until_idle().unwrap(); + + assert_eq!( + network + .node("a") + .unwrap() + .ledger() + .expected_leader_for_next_block(), + Some(carol.address().to_string()) + ); + network.node_mut("c").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block9 = network.node_mut("c").unwrap().mine_one_at(9).unwrap(); + mined_by.push(block9.miner.clone()); + network.deliver_until_idle().unwrap(); + + let final_tip = network.node("a").unwrap().ledger().status().tip_hash; + for id in ["a", "b", "c"] { + assert_eq!(network.node(id).unwrap().ledger().status().height, 9); + assert_eq!( + network.node(id).unwrap().ledger().status().tip_hash, + final_tip + ); + } + for wallet in [&alice, &bob, &carol] { + assert!( + mined_by.iter().any(|miner| miner == wallet.address()), + "{} never mined", + wallet.address() + ); + } +} + +#[test] +fn persisted_joined_nodes_restart_and_keep_syncing_without_tcp() { + let temp = tempdir().unwrap(); + let alice = Wallet::from_seed("persistent-flow-alice"); + let bob = Wallet::from_seed("persistent-flow-bob"); + let carol = Wallet::from_seed("persistent-flow-carol"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), 50); + let alice_ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5) + .unwrap(); + + let mut network = InMemoryNetwork::default(); + network.insert( + "a", + NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK), + ); + + network.node_mut("a").unwrap().burn(1).unwrap(); + network.node_mut("a").unwrap().mine_one_at(1).unwrap(); + network.deliver_until_idle().unwrap(); + + let bob_store = SqliteChainStore::open(temp.path().join("bob.sqlite3")).unwrap(); + bob_store + .save(&network.node("a").unwrap().chain_snapshot()) + .unwrap(); + let bob_joined_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap(); + network.insert( + "b", + NodeCore::from_ledger(bob.clone(), bob_joined_ledger, DEFAULT_BURN_PER_BLOCK), + ); + assert_eq!( + network.node("b").unwrap().ledger().status().tip_hash, + network.node("a").unwrap().ledger().status().tip_hash + ); + + network + .node_mut("a") + .unwrap() + .transfer(bob.address(), 10) + .unwrap(); + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + network.node_mut("a").unwrap().mine_one_at(2).unwrap(); + network.deliver_until_idle().unwrap(); + assert_eq!( + network + .node("b") + .unwrap() + .ledger() + .balance_of(bob.address()), + 10 + ); + + bob_store + .save(&network.node("b").unwrap().chain_snapshot()) + .unwrap(); + let bob_restarted_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap(); + network.insert( + "b", + NodeCore::from_ledger(bob.clone(), bob_restarted_ledger, DEFAULT_BURN_PER_BLOCK), + ); + assert_eq!( + network.node("b").unwrap().ledger().status().tip_hash, + network.node("a").unwrap().ledger().status().tip_hash, + "restarted Bob should resume the persisted chain tip" + ); + + let carol_store = SqliteChainStore::open(temp.path().join("carol.sqlite3")).unwrap(); + carol_store + .save(&network.node("a").unwrap().chain_snapshot()) + .unwrap(); + let carol_joined_ledger = Ledger::from_snapshot(carol_store.load().unwrap().unwrap()).unwrap(); + network.insert( + "c", + NodeCore::from_ledger(carol, carol_joined_ledger, DEFAULT_BURN_PER_BLOCK), + ); + + network.node_mut("b").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + network.node_mut("a").unwrap().mine_one_at(3).unwrap(); + network.deliver_until_idle().unwrap(); + assert_eq!( + network + .node("a") + .unwrap() + .ledger() + .expected_leader_for_next_block() + .as_deref(), + Some(bob.address()) + ); + + network.node_mut("b").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let bob_block = network.node_mut("b").unwrap().mine_one_at(4).unwrap(); + assert_eq!(bob_block.miner, bob.address()); + network.deliver_until_idle().unwrap(); + + let final_status = network.node("a").unwrap().ledger().status(); + for id in ["b", "c"] { + assert_eq!( + network.node(id).unwrap().ledger().status().height, + final_status.height, + "{id} did not catch up after Bob restarted" + ); + assert_eq!( + network.node(id).unwrap().ledger().status().tip_hash, + final_status.tip_hash, + "{id} ended on a different tip after Bob restarted" + ); + } + + bob_store + .save(&network.node("b").unwrap().chain_snapshot()) + .unwrap(); + assert_eq!( + bob_store + .load() + .unwrap() + .unwrap() + .blocks + .last() + .unwrap() + .height, + final_status.height + ); +} + +#[test] +fn mined_block_gossip_does_not_include_full_chain_snapshot() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let wallets = vec![alice.clone(), bob.clone()]; + let allocations = allocations(&wallets, 1_000); + + let mut alice_node = NodeCore::new(NodeConfig { + wallet: alice, + genesis_allocations: allocations.clone(), + vdf_rounds: 10, + burn_per_block: 1, + }); + + let plan = alice_node.prepare_automatic_mining(1); + let burn_outbox = alice_node.drain_outbox(); + assert_eq!(burn_outbox.len(), 1); + assert!(matches!( + burn_outbox[0], + luun::app::GossipEnvelope::Transaction(_) + )); + + let work = plan.work.unwrap(); + let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); + alice_node + .complete_prepared_block(work, vdf_output) + .unwrap(); + let block_outbox = alice_node.drain_outbox(); + + assert_eq!(block_outbox.len(), 1); + assert!(matches!( + block_outbox[0], + luun::app::GossipEnvelope::Block(_) + )); +} + +#[test] +fn received_transaction_is_rebroadcast_to_other_peers_without_networking() { + let names = ["alice", "bob", "carol"]; + let wallets = wallets(&names); + let allocations = allocations(&wallets, 1_000); + let alice = wallets[0].clone(); + let bob = wallets[1].clone(); + let carol = wallets[2].clone(); + + let mut carol_node = node("carol", carol, allocations.clone()); + let mut hub = node("alice", alice, allocations.clone()); + let mut bob_node = node("bob", bob, allocations); + + let tx = carol_node.burn(25).unwrap(); + carol_node.drain_outbox(); + + hub.receive(luun::app::GossipEnvelope::Transaction(tx.clone())) + .unwrap(); + let forwarded = hub.drain_outbox(); + assert_eq!(forwarded.len(), 1); + assert!(matches!( + forwarded[0], + luun::app::GossipEnvelope::Transaction(_) + )); + + for envelope in forwarded { + bob_node.receive(envelope).unwrap(); + } + assert!( + bob_node + .ledger() + .pending() + .iter() + .any(|pending| pending.signature() == tx.signature()) + ); + + hub.receive(luun::app::GossipEnvelope::Transaction(tx)) + .unwrap(); + assert!(hub.drain_outbox().is_empty()); +} + +#[test] +fn mempool_gossip_repairs_future_nonce_gap_without_networking() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let wallets = vec![alice.clone(), bob.clone()]; + let allocations = allocations(&wallets, 1_000); + let mut alice_node = node("alice", alice, allocations.clone()); + let mut bob_node = node("bob", bob, allocations); + + let first = alice_node.burn(1).unwrap(); + let second = alice_node.burn(1).unwrap(); + alice_node.drain_outbox(); + + bob_node + .receive(luun::app::GossipEnvelope::Transaction(second.clone())) + .unwrap(); + assert_eq!(bob_node.ledger().pending().len(), 1); + + let mut requests = Vec::new(); + for envelope in alice_node.mempool_gossip() { + match envelope { + luun::app::GossipEnvelope::Inventory { txs, blocks } => { + requests.extend(bob_node.missing_inventory_requests(&txs, &blocks)); + } + other => bob_node.receive(other).unwrap(), + } + } + for request in requests { + match request { + luun::app::GossipEnvelope::TransactionRequest { signatures } => { + bob_node + .receive(luun::app::GossipEnvelope::Transactions { + transactions: alice_node.transactions_by_signature(&signatures), + }) + .unwrap(); + } + other => bob_node.receive(other).unwrap(), + } + } + let block = alice_node.mine_one_at(1).unwrap(); + let signatures = block + .transactions + .iter() + .map(|tx| tx.signature()) + .collect::<Vec<_>>(); + + assert!(signatures.contains(&first.signature())); + assert!(signatures.contains(&second.signature())); +} + +#[test] +fn received_block_is_rebroadcast_to_other_peers_without_networking() { + let names = ["alice", "bob", "carol"]; + let wallets = wallets(&names); + let allocations = allocations(&wallets, 1_000); + let alice = wallets[0].clone(); + let bob = wallets[1].clone(); + let carol = wallets[2].clone(); + + let mut miner = node("alice", alice, allocations.clone()); + let mut hub = node("bob", bob, allocations.clone()); + let mut carol_node = node("carol", carol, allocations); + + miner.burn(10).unwrap(); + miner.drain_outbox(); + let block = miner.mine_one_at(1).unwrap(); + miner.drain_outbox(); + + hub.receive(luun::app::GossipEnvelope::Block(block.clone())) + .unwrap(); + let forwarded = hub.drain_outbox(); + assert_eq!(forwarded.len(), 1); + assert!(matches!(forwarded[0], luun::app::GossipEnvelope::Block(_))); + + for envelope in forwarded { + carol_node.receive(envelope).unwrap(); + } + assert_eq!(carol_node.ledger().height(), 1); + assert_eq!( + carol_node.ledger().status().tip_hash, + miner.ledger().status().tip_hash + ); + + hub.receive(luun::app::GossipEnvelope::Block(block)) + .unwrap(); + assert!(hub.drain_outbox().is_empty()); +} + +#[test] +fn imported_snapshot_blocks_are_rebroadcast_without_networking() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let wallets = vec![alice.clone(), bob.clone()]; + let allocations = allocations(&wallets, 1_000); + let mut miner = node("alice", alice, allocations.clone()); + let mut hub = node("bob", bob, allocations); + + miner.burn(1).unwrap(); + miner.drain_outbox(); + miner.mine_one_at(1).unwrap(); + miner.drain_outbox(); + + miner.burn(1).unwrap(); + miner.drain_outbox(); + miner.mine_one_at(2).unwrap(); + miner.drain_outbox(); + + hub.import_chain_snapshot(miner.chain_snapshot()).unwrap(); + let outbox = hub.drain_outbox(); + assert_eq!(outbox.len(), 1); + match &outbox[0] { + luun::app::GossipEnvelope::Blocks { blocks } => { + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].height, 1); + assert_eq!(blocks[1].height, 2); + } + other => panic!("expected imported blocks gossip, got {other:?}"), + } +} + +#[test] +fn multiple_peers_can_contribute_burns_to_the_same_lottery_block() { + let names = ["alice", "bob", "carol", "dave"]; + let wallets = wallets(&names); + let allocations = allocations(&wallets, 1_000); + let mut network = InMemoryNetwork::default(); + + for (name, wallet) in names.iter().zip(wallets.clone()) { + network.insert(*name, node(name, wallet, allocations.clone())); + } + + for (name, amount) in names.iter().zip([10, 20, 30, 40]) { + network.node_mut(name).unwrap().burn(amount).unwrap(); + } + network.deliver_until_idle().unwrap(); + network.node_mut("alice").unwrap().mine_one().unwrap(); + network.deliver_until_idle().unwrap(); + + for name in names { + let ledger = network.node(name).unwrap().ledger(); + let block = &ledger.chain()[1]; + let burned = block + .transactions + .iter() + .filter(|tx| tx.is_burn()) + .map(|tx| tx.amount()) + .sum::<Amount>(); + + assert_eq!(block.transactions.len(), 4); + assert_eq!(burned, 100); + assert!(ledger.expected_leader_for_next_block().is_some()); + } +} + +#[test] +fn peer_book_tracks_multiple_peers_without_networking() { + let mut peers = PeerBook::from_addresses(vec![ + "127.0.0.1:9444".to_string(), + "127.0.0.1:9445".to_string(), + "127.0.0.1:9444".to_string(), + ]); + + peers.record_sent("127.0.0.1:9444", 2); + peers.record_status("127.0.0.1:9444", 12, "tip-hash".to_string()); + peers.record_error("127.0.0.1:9445", "connection refused"); + peers.record_received("127.0.0.1:9555", 1); + peers.record_inbound_error("127.0.0.1:56666", "invalid nonce"); + + let mut list = peers.list(); + list.sort_by(|left, right| left.address.cmp(&right.address)); + assert_eq!(list.len(), 4); + + let outbound_addresses = peers.addresses(); + assert_eq!(outbound_addresses.len(), 2); + assert!(outbound_addresses.contains(&"127.0.0.1:9444".to_string())); + assert!(outbound_addresses.contains(&"127.0.0.1:9445".to_string())); + assert!(!outbound_addresses.contains(&"127.0.0.1:56666".to_string())); + + let sent_peer = list + .iter() + .find(|peer| peer.address == "127.0.0.1:9444") + .unwrap(); + assert_eq!(sent_peer.messages_sent, 2); + assert_eq!(sent_peer.last_known_height, Some(12)); + assert_eq!(sent_peer.last_known_tip_hash.as_deref(), Some("tip-hash")); + assert_eq!(sent_peer.last_error, None); + + let failed_peer = list + .iter() + .find(|peer| peer.address == "127.0.0.1:9445") + .unwrap(); + assert_eq!( + failed_peer.last_error.as_deref(), + Some("connection refused") + ); + + let inbound_peer = list + .iter() + .find(|peer| peer.address == "127.0.0.1:9555") + .unwrap(); + assert_eq!(inbound_peer.direction, PeerDirection::Inbound); + assert_eq!(inbound_peer.messages_received, 1); + + let inbound_error = list + .iter() + .find(|peer| peer.address == "127.0.0.1:56666") + .unwrap(); + assert_eq!(inbound_error.direction, PeerDirection::Inbound); + assert_eq!(inbound_error.last_error.as_deref(), Some("invalid nonce")); +} + +#[test] +fn chain_snapshot_round_trips_ledger_state() { + 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); + ledger + .submit_transaction(alice.burn(10, ledger.next_nonce(alice.address()))) + .unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + ledger.apply_block(block).unwrap(); + + let restored = Ledger::from_snapshot(ledger.snapshot()).unwrap(); + assert_eq!(restored.status().height, ledger.status().height); + assert_eq!(restored.status().tip_hash, ledger.status().tip_hash); + assert_eq!( + restored.balance_of(alice.address()), + ledger.balance_of(alice.address()) + ); +} + +#[test] +fn friend_node_can_join_snapshot_from_started_chain() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + + let mut alice_genesis = BTreeMap::new(); + alice_genesis.insert(alice.address().to_string(), 1_000); + let mut alice_node = node("alice", alice.clone(), alice_genesis); + alice_node.burn(1).unwrap(); + alice_node.automatic_mine_once(1); + + let joined_ledger = Ledger::from_snapshot(alice_node.chain_snapshot()).unwrap(); + let mut bob_node = NodeCore::from_ledger(bob.clone(), joined_ledger, DEFAULT_BURN_PER_BLOCK); + + assert_eq!( + bob_node.ledger().status().tip_hash, + alice_node.ledger().status().tip_hash + ); + assert_eq!(bob_node.ledger().status().height, 1); + assert_eq!(bob_node.ledger().balance_of(bob.address()), 0); + + let outcome = bob_node.automatic_mine_once(2); + assert!(outcome.burned.is_none()); +} + +#[test] +fn running_node_rejects_snapshot_from_different_genesis() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + + let mut alice_genesis = BTreeMap::new(); + alice_genesis.insert(alice.address().to_string(), 1_000); + let alice_node = node("alice", alice, alice_genesis); + + let mut bob_genesis = BTreeMap::new(); + bob_genesis.insert(bob.address().to_string(), 1_000); + let mut bob_node = node("bob", bob, bob_genesis); + + let error = bob_node + .import_chain_snapshot(alice_node.chain_snapshot()) + .unwrap_err(); + + assert!(error.to_string().contains("genesis")); +} + +#[test] +fn same_height_fork_snapshot_does_not_reorg() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + let wallets = vec![alice.clone(), bob.clone()]; + let shared_genesis = allocations(&wallets, 1_000); + 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, &alice, &local_first_fork_hash, 1).unwrap(); + + let local_tip = local.status().tip_hash; + assert!(!local.extend_from_snapshot(remote.snapshot()).unwrap()); + assert_eq!(local.status().tip_hash, local_tip); +} + +#[test] +fn fork_choice_preflight_rejects_non_matching_genesis_before_scoring() { + let alice = Wallet::from_seed("preflight-genesis-alice"); + let bob = Wallet::from_seed("preflight-genesis-bob"); + let mut local_genesis = BTreeMap::new(); + local_genesis.insert(alice.address().to_string(), 1_000); + let mut remote_genesis = BTreeMap::new(); + remote_genesis.insert(bob.address().to_string(), 1_000); + let mut local = Ledger::new(local_genesis, 1); + let mut remote = Ledger::new(remote_genesis, 1); + + mine_wallet_burn_block(&mut local, &alice, 1); + for timestamp in 1..=3 { + mine_wallet_burn_block(&mut remote, &bob, timestamp); + } + + let local_tip = local.status().tip_hash; + let error = local.extend_from_snapshot(remote.snapshot()).unwrap_err(); + + assert!(error.to_string().contains("genesis")); + assert_eq!(local.status().tip_hash, local_tip); +} + +#[test] +fn fork_choice_preflight_rejects_invalid_fork_before_vrf_scoring() { + let alice = Wallet::from_seed("preflight-invalid-alice"); + let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); + let mut common = Ledger::new(shared_genesis, 1); + for timestamp in 1..=5 { + mine_wallet_burn_block(&mut common, &alice, timestamp); + } + + let mut local = common.clone(); + let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); + mine_wallet_burn_block(&mut local, &alice, 7); + mine_wallet_burn_block(&mut local, &alice, 8); + + let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); + assert!(remote.chain()[6].hash < local.chain()[6].hash); + let mut invalid_snapshot = remote.snapshot(); + if let Some(transaction) = invalid_snapshot.blocks[6].transactions.first_mut() { + match transaction { + luun::domain::Transaction::Burn { signature, .. } + | luun::domain::Transaction::Transfer { signature, .. } => signature.push_str("00"), + } + } + + let local_tip = local.status().tip_hash; + let error = local.extend_from_snapshot(invalid_snapshot).unwrap_err(); + + assert!(error.to_string().contains("invalid")); + assert_eq!(local.status().height, 8); + assert_eq!(local.status().tip_hash, local_tip); +} + +#[test] +fn fork_conflict_before_last_six_blocks_is_finalized_even_if_remote_is_longer() { + let alice = Wallet::from_seed("finality-alice"); + let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); + let mut common = Ledger::new(shared_genesis, 1); + mine_wallet_burn_block(&mut common, &alice, 1); + + let mut local = common.clone(); + for timestamp in 2..=8 { + mine_wallet_burn_block(&mut local, &alice, timestamp); + } + + let mut remote = common; + for timestamp in 20..=29 { + mine_wallet_burn_block(&mut remote, &alice, timestamp); + } + + assert_eq!(local.status().height, 8); + assert_eq!(remote.status().height, 11); + let finalized_local_tip = local.status().tip_hash; + + assert!( + !local.extend_from_snapshot(remote.snapshot()).unwrap(), + "forks that rewrite blocks before the last six should not be accepted" + ); + assert_eq!(local.status().height, 8); + assert_eq!(local.status().tip_hash, finalized_local_tip); +} + +#[test] +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); + for timestamp in 1..=5 { + mine_wallet_burn_block(&mut common, &alice, timestamp); + } + + let mut local = common.clone(); + let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); + mine_wallet_burn_block(&mut local, &alice, 7); + mine_wallet_burn_block(&mut local, &alice, 8); + + let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); + + assert_eq!(local.status().height, 8); + assert_eq!(remote.status().height, 6); + assert!(remote.status().height + 2 >= local.status().height); + assert!( + remote.chain()[6].hash < local.chain()[6].hash, + "test setup should give the remote fork the better VRF leader score" + ); + let remote_tip = remote.status().tip_hash; + + assert!( + !local.extend_from_snapshot(remote.snapshot()).unwrap(), + "a shorter fork should not beat greater positive chain quality" + ); + assert_eq!(local.status().height, 8); + assert_ne!(local.status().tip_hash, remote_tip); +} + +#[test] +fn better_vrf_fork_inside_last_six_loses_when_more_than_two_blocks_shorter() { + let alice = Wallet::from_seed("too-short-vrf-alice"); + let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); + let mut common = Ledger::new(shared_genesis, 1); + for timestamp in 1..=5 { + mine_wallet_burn_block(&mut common, &alice, timestamp); + } + + let mut local = common.clone(); + let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); + mine_wallet_burn_block(&mut local, &alice, 7); + mine_wallet_burn_block(&mut local, &alice, 8); + mine_wallet_burn_block(&mut local, &alice, 9); + + let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); + + assert_eq!(local.status().height, 9); + assert_eq!(remote.status().height, 6); + assert!(remote.chain()[6].hash < local.chain()[6].hash); + let local_tip = local.status().tip_hash; + + assert!( + !local.extend_from_snapshot(remote.snapshot()).unwrap(), + "even a better VRF fork should not win when more than two blocks shorter" + ); + assert_eq!(local.status().height, 9); + assert_eq!(local.status().tip_hash, local_tip); +} + +#[test] +fn transactions_from_abandoned_fork_blocks_return_to_mempool_after_switch() { + let alice = Wallet::from_seed("reorg-alice"); + let bob = Wallet::from_seed("reorg-bob"); + 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_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(); + let abandoned_transfer = bob.transfer(carol.address(), 7, local.next_nonce(bob.address())); + local + .submit_transaction(abandoned_transfer.clone()) + .unwrap(); + mine_wallet_burn_block(&mut local, &alice, 2); + + let mut remote = common; + for timestamp in 20..=23 { + mine_wallet_burn_block(&mut remote, &alice, timestamp); + } + + assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); + assert!( + local + .pending() + .iter() + .any(|tx| tx.signature() == abandoned_transfer.signature()), + "transactions mined only on the abandoned fork should return to the mempool" + ); +} + +#[test] +fn longer_valid_fork_snapshot_reorgs_and_preserves_local_transactions() { + let alice = Wallet::from_seed("alice"); + 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_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, 1).unwrap(); + local.apply_block(local_block).unwrap(); + let local_transfer = bob.transfer(alice.address(), 5, local.next_nonce(bob.address())); + local.submit_transaction(local_transfer.clone()).unwrap(); + + remote + .submit_transaction(alice.burn(1, remote.next_nonce(alice.address()))) + .unwrap(); + let remote_block_1 = remote.mine_next_block(&alice, 1).unwrap(); + remote.apply_block(remote_block_1).unwrap(); + remote + .submit_transaction(alice.burn(1, remote.next_nonce(alice.address()))) + .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; + assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); + assert_eq!(local.status().height, 2); + assert_eq!(local.status().tip_hash, remote_tip); + assert!( + local + .pending() + .iter() + .any(|tx| tx.signature() == local_transfer.signature()) + ); +} + +#[test] +fn node_receives_chain_snapshot_envelope_when_joining_without_tcp() { + let alice = Wallet::from_seed("alice"); + let bob = Wallet::from_seed("bob"); + + let wallets = vec![alice.clone(), bob.clone()]; + let shared_genesis = allocations(&wallets, 1_000); + let mut alice_node = node("alice", alice, shared_genesis.clone()); + alice_node.burn(1).unwrap(); + alice_node.mine_one().unwrap(); + + let mut bob_node = node("bob", bob, shared_genesis); + + bob_node + .receive(luun::app::GossipEnvelope::ChainSnapshot( + alice_node.chain_snapshot(), + )) + .unwrap(); + + assert_eq!( + bob_node.ledger().status().tip_hash, + alice_node.ledger().status().tip_hash + ); +}