iuna

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

commit 818ec8a3c0ab3c5971395ec4f216021aa672cdab
parent 07de2c3e204034d0306d42bdcc8a2d803b224191
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun, 26 Jul 2026 23:13:12 +0200

Rename project to iuna

Diffstat:
M.gitignore | 2+-
MCargo.lock | 38+++++++++++++++++++-------------------
MCargo.toml | 2+-
MREADME.md | 36++++++++++++++++++------------------
Aassets/iuna-ui.js | 1080+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dassets/luun-ui.js | 1080-------------------------------------------------------------------------------
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+-
Mdocs/genesis.md | 6+++---
Msrc/adapters/config_store.rs | 30+++++++++++++++---------------
Msrc/adapters/http.rs | 102++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/adapters/stratum.rs | 4++--
Msrc/app.rs | 12++++++------
Msrc/domain.rs | 76++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/main.rs | 64++++++++++++++++++++++++++++++++--------------------------------
Atests/iuna.rs | 2129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtests/luun.rs | 2129-------------------------------------------------------------------------------
Mtests/properties.rs | 40++++++++++++++++++++--------------------
Mwww/index.html | 30+++++++++++++++---------------
25 files changed, 3446 insertions(+), 3446 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,2 +1,2 @@ /target -/.luun +/.iuna diff --git a/Cargo.lock b/Cargo.lock @@ -501,6 +501,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] +name = "iuna" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chacha20poly1305", + "ed25519-dalek", + "getrandom 0.2.17", + "pbkdf2", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tower", +] + +[[package]] name = "libc" version = "0.2.188" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -539,25 +558,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] -name = "luun" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "chacha20poly1305", - "ed25519-dalek", - "getrandom 0.2.17", - "pbkdf2", - "rusqlite", - "serde", - "serde_json", - "sha2", - "tempfile", - "tokio", - "tower", -] - -[[package]] name = "matchit" version = "0.8.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 = "luun" +name = "iuna" version = "0.1.0" edition = "2024" diff --git a/README.md b/README.md @@ -1,6 +1,6 @@ -# Luun +# iuna -Luun is a tiny L1 prototype built from the node first, then explained as it grows. +iuna 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, block finalizer, PoW 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. @@ -12,9 +12,9 @@ To start a fresh chain, run genesis mode from an empty wallet path: cargo run -- --genesis --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 verifies the generated 24-word recovery phrase with a 4-word check, stores the wallet in `.luun/wallet.json`, and stores runtime config in `.luun/config.json`. +Open `http://127.0.0.1:18661` and complete the initial setup modal. The setup flow verifies the generated 24-word recovery phrase with a 4-word check, stores the wallet in `.iuna/wallet.json`, and stores runtime config in `.iuna/config.json`. -`--genesis` only works when the wallet file does not already exist and `.luun/chain.sqlite3` does not already contain a blockchain. It creates a fresh setup wallet, opens the setup modal so the recovery phrase can be recorded, 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 does not already exist and `.iuna/chain.sqlite3` does not already contain a blockchain. It creates a fresh setup wallet, opens the setup modal so the recovery phrase can be recorded, 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`. For setup-only local development without creating a chain yet: @@ -22,11 +22,11 @@ For setup-only local development without creating a chain yet: cargo run -- --http 127.0.0.1:18661 --p2p 127.0.0.1:9444 ``` -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. +For fast local development, set `IUNA_DEV_SKIP_SEED_VERIFY=1` before starting the node to show a setup-only skip button for the recovery phrase check. Block finalization 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 after the launch profile's maturity delay. The current devnet uses a 3-block maturity delay and a 3-block eligibility window: a burn included at height `h` can win heights `h + 3` through `h + 5`, then expires if it was not selected. Eligible tickets are ranked deterministically for each height. Rank 0 gets the normal VDF delay; rank 1 may finalize with double VDF rounds, rank 2 with triple rounds, and so on. That lets the chain continue if the first selected finalizer is offline, while the VDF remains the clock. -Genesis leaves the starter wallet with 100 spendable LUUN after the 1-LUUN bootstrap burn creates launch tickets for blocks 1, 2, and 3, and the genesis block pays its 100-LUUN reward. `--genesis` starts the automatic burn rate at 100 LUUN per block, so the starter can create the block 1 burn that becomes eligible at block 4. +Genesis leaves the starter wallet with 100 spendable IUNA after the 1-IUNA bootstrap burn creates launch tickets for blocks 1, 2, and 3, and the genesis block pays its 100-IUNA reward. `--genesis` starts the automatic burn rate at 100 IUNA per block, so the starter can create the block 1 burn that becomes eligible at block 4. The management UI is a small AlpineJS app served from local vendored assets. It polls JSON endpoints every few seconds and includes: @@ -39,7 +39,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 .luun-bob --http 127.0.0.1:18662 --p2p 127.0.0.1:9445 --join 127.0.0.1:9444 +cargo run -- --data-dir .iuna-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 finalization starts and announces this node's P2P listener back to that peer, so newly finalized 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. @@ -70,34 +70,34 @@ 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 .luun-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host:9444 +cargo run -- --data-dir .iuna-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 LUUN after the bootstrap burn and genesis reward, and `--genesis` starts it with a 100-LUUN automatic burn rate. After the starter finalizes additional blocks, 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. +Friends who join after you start will adopt your genesis and current chain. The starter wallet begins with 100 spendable IUNA after the bootstrap burn and genesis reward, and `--genesis` starts it with a 100-IUNA automatic burn rate. After the starter finalizes additional blocks, send friends IUNA from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-IUNA automatic burn unless it is configured otherwise. ## Bitaxe / Stratum -Run a Stratum V1 listener when you want SHA-256 ASIC miners such as a Bitaxe to create Luun PoW mine actions: +Run a Stratum V1 listener when you want SHA-256 ASIC miners such as a Bitaxe to create iuna PoW mine actions: ```sh -cargo run -- --data-dir .luun --stratum 0.0.0.0:3333 +cargo run -- --data-dir .iuna --stratum 0.0.0.0:3333 ``` -Point the miner at the node's Stratum host and port, and use your Luun wallet address as the worker username. The usual `address.worker` format is also accepted; Luun pays the address before the dot. Accepted ASIC shares become Luun mine actions in the node mempool and are gossiped to peers. The Stratum job commits to the current chain tip, recipient address, mine reward, finalizer fee, and Luun mine difficulty. +Point the miner at the node's Stratum host and port, and use your iuna wallet address as the worker username. The usual `address.worker` format is also accepted; iuna pays the address before the dot. Accepted ASIC shares become iuna mine actions in the node mempool and are gossiped to peers. The Stratum job commits to the current chain tip, recipient address, mine reward, finalizer fee, and iuna mine difficulty. -The genesis block bootstraps the chain with a 1-LUUN burn from the starter wallet. Genesis turns that burn into launch tickets for blocks 1 through 3 so the chain can move until normal burn tickets mature. Burns included after genesis create one-shot tickets through a deterministic weighted lottery: a burn of `X` among total eligible burn weight `Y` has `X / Y` chance for that block. 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-IUNA burn from the starter wallet. Genesis turns that burn into launch tickets for blocks 1 through 3 so the chain can move until normal burn tickets mature. Burns included after genesis create one-shot tickets through a deterministic weighted lottery: a burn of `X` among total eligible burn weight `Y` has `X / Y` chance for that block. 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 eligible 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 finalizer, timestamp, finalizer 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 finalizer reward is the block's total transaction fees. Transfers, burns, and PoW mine actions all set fees as LUUN per serialized byte; the node calculates the total fee from the final transaction size before submitting it. The automatic burn setting has a burn amount and a fee rate; the burn amount is the ticket weight, while the resulting fee is paid to the finalizer that includes it. The finalizer includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. PoW mine actions introduce new LUUN separately. The default burn is 0 LUUN per block with a 0.000001-LUUN-per-byte fee rate, 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 base block finalizer reward is the block's total transaction fees. Transfers, burns, and PoW mine actions all set fees as IUNA per serialized byte; the node calculates the total fee from the final transaction size before submitting it. The automatic burn setting has a burn amount and a fee rate; the burn amount is the ticket weight, while the resulting fee is paid to the finalizer that includes it. The finalizer includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. PoW mine actions introduce new IUNA separately. The default burn is 0 IUNA per block with a 0.000001-IUNA-per-byte fee rate, so new nodes can join before they own IUNA. Genesis starters begin at 100 IUNA per block; after another wallet has IUNA, 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 -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. +iuna creates a new wallet file the first time a node starts or joins a chain. By default it lives at `.iuna/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. During setup, the UI password encrypts the wallet seed at rest with PBKDF2-SHA256 and ChaCha20-Poly1305. On restart the node can read the wallet address from metadata, but the wallet remains locked until the password is entered in the management UI. Locked nodes can sync and show chain state, but cannot sign burns, transfers, or finalized blocks. @@ -105,15 +105,15 @@ There is no default wallet seed in the binary. Keep the wallet file and recovery ## Node Config -Luun stores UI setup state, configured peers, the configured automatic burn amount, and the burn/mine fee rates 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. +iuna stores UI setup state, configured peers, the configured automatic burn amount, and the burn/mine fee rates 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 -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. +iuna 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-LUUN base rewards, 100kB blocks, rolling-window leader tickets, leader proofs, fork choice, launch profile, and VDF checks. +- `src/domain.rs`: wallet, fee-paying transactions, balances, genesis burn bootstrap, 100-IUNA base rewards, 100kB blocks, rolling-window leader tickets, leader proofs, fork choice, launch profile, and VDF checks. - `src/app.rs`: node use cases, automatic VDF-paced finalization, 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/iuna-ui.js b/assets/iuna-ui.js @@ -0,0 +1,1080 @@ +window.iunaApp = function iunaApp() { + return { + tab: "wallet", + status: {}, + blocks: [], + selectedBlock: null, + selectedTransaction: null, + loadingOlder: false, + hasMoreBlocks: true, + walletTxs: [], + walletUtxos: [], + mempool: [], + peers: [], + p2pMetrics: {}, + config: { setup_complete: false }, + auth: { configured: false, authenticated: false }, + authLoaded: false, + authPassword: "", + authPasswordConfirm: "", + loginPassword: "", + authFeedback: null, + 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, + burnFee: 1, + burnFeeDraft: "0.000001", + miningEnabled: false, + powMiningEnabled: false, + powMineFee: 1, + powMineFeeDraft: "0.000001", + powMineFeeDirty: false, + burnAmountDirty: false, + transferTo: "", + transferAmount: null, + transferFee: "0.000001", + feeEstimates: { transfer: null, burn: null, mine: null }, + feeEstimateTimer: null, + showSendAdvanced: false, + selectedTransferUtxos: [], + peerAddress: "", + flash: null, + flashTimer: null, + showWalletUtxos: false, + showPowDifficultyInfo: false, + lastUpdated: null, + pollHandle: null, + hashListenerInstalled: false, + newBlockHashes: new Set(), + newBlockTimer: null, + blockPageSize: 20, + + init() { + this.bootstrap(); + }, + + async bootstrap() { + await this.refreshAuth(); + if (this.showingAuth()) return; + await this.bootstrapAuthenticated(); + }, + + async bootstrapAuthenticated() { + await this.refreshConfig(); + if (!this.config.setup_complete) { + await this.refreshWalletSetup(); + } + this.tab = this.tabFromHash(); + if (!this.hashListenerInstalled) { + window.addEventListener("hashchange", () => { + this.tab = this.tabFromHash(); + }); + this.hashListenerInstalled = true; + } + await this.refresh(); + if (!this.pollHandle) { + 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: "iuna", + mining: "Mining", + p2p: "P2P", + chain: "Chain", + }[this.tab] || "iuna"; + }, + + showingSetup() { + return this.authLoaded && !this.showingAuth() && !this.config.setup_complete; + }, + + showingAuth() { + return this.authLoaded && (!this.auth.configured || !this.auth.authenticated); + }, + + async refreshAuth() { + this.auth = await this.fetchJson("/api/auth/status"); + this.authLoaded = true; + }, + + async setupPassword() { + try { + this.authFeedback = null; + if (this.authPassword !== this.authPasswordConfirm) { + throw new Error("Passwords do not match"); + } + await this.postAuth("/api/auth/setup", this.authPassword); + this.authPassword = ""; + this.authPasswordConfirm = ""; + await this.refreshAuth(); + await this.bootstrapAuthenticated(); + this.showFlash("Password set", "success"); + } catch (error) { + this.showAuthFeedback(error.message, "error"); + } + }, + + async login() { + try { + this.authFeedback = null; + await this.postAuth("/api/auth/login", this.loginPassword); + this.loginPassword = ""; + await this.refreshAuth(); + await this.bootstrapAuthenticated(); + this.showFlash("Logged in", "success"); + } catch (error) { + this.showAuthFeedback(error.message, "error"); + } + }, + + async postAuth(path, password) { + const response = await fetch(path, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password }), + }); + const text = await response.text(); + let payload = { ok: response.ok, error: null }; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = { ok: false, error: text }; + } + } + if (!response.ok || !payload.ok) { + throw new Error(payload.error || `${path} returned ${response.status}`); + } + return payload; + }, + + async logout() { + try { + await this.postAuth("/api/auth/logout", ""); + await this.refreshAuth(); + this.showFlash("Locked", "success"); + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + 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, walletTxs, walletUtxos, mempool, peers, p2pMetrics] = await Promise.all([ + this.fetchJson("/api/config"), + this.fetchJson("/api/status"), + this.fetchJson("/api/blocks?limit=30"), + this.fetchJson("/api/wallet/transactions"), + this.fetchJson("/api/wallet/utxos"), + this.fetchJson("/api/mempool"), + this.fetchJson("/api/peers"), + this.fetchJson("/api/p2p/metrics"), + ]); + this.config = config; + if (!this.config.setup_complete) { + await this.refreshWalletSetup(); + } + this.status = status; + this.mergeFreshBlocks(blocks, { animateHead: true }); + this.walletTxs = walletTxs; + this.walletUtxos = walletUtxos; + this.mempool = mempool; + this.peers = peers; + this.p2pMetrics = p2pMetrics; + this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; + this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; + this.miningEnabled = status.mining?.automatic ?? this.miningEnabled; + this.powMiningEnabled = status.mining?.pow_mining_enabled ?? this.powMiningEnabled; + this.powMineFee = status.mining?.automatic_pow_mine_fee ?? this.powMineFee; + if (!this.burnAmountDirty) { + this.burnAmountDraft = this.amountLabel(this.burnAmount); + this.burnFeeDraft = this.amountLabel(this.burnFee); + } + if (!this.powMineFeeDirty) { + this.powMineFeeDraft = this.amountLabel(this.powMineFee); + } + this.lastUpdated = new Date(); + this.scheduleFeeEstimates(); + } catch (error) { + if (String(error.message || "").includes("401")) { + await this.refreshAuth(); + return; + } + 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; + }, + + openTransactionModal(tx, context = {}) { + this.selectedTransaction = { tx, context }; + }, + + closeTransactionModal() { + this.selectedTransaction = null; + }, + + openWalletUtxosModal() { + this.showWalletUtxos = true; + }, + + closeWalletUtxosModal() { + this.showWalletUtxos = false; + }, + + openPowDifficultyInfo() { + this.showPowDifficultyInfo = true; + }, + + closePowDifficultyInfo() { + this.showPowDifficultyInfo = false; + }, + + closeModals() { + this.closeTransactionModal(); + this.closeWalletUtxosModal(); + this.closePowDifficultyInfo(); + }, + + 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)) { + if (Array.isArray(value)) { + for (const item of value) body.append(key, item); + } else { + body.set(key, value); + } + } + const response = await fetch(path, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const text = await response.text(); + let payload = { ok: response.ok, error: null }; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = { ok: false, error: text }; + } + } + if (!response.ok || !payload.ok) { + throw new Error(payload?.error || `${path} returned ${response.status}`); + } + await this.refresh(); + this.showFlash(successMessage, "success"); + }, + + scheduleFeeEstimates() { + if (this.feeEstimateTimer) clearTimeout(this.feeEstimateTimer); + this.feeEstimateTimer = setTimeout(() => this.refreshFeeEstimates(), 220); + }, + + async refreshFeeEstimates() { + if (this.showingAuth()) return; + await Promise.all([ + this.refreshBurnFeeEstimate(), + this.refreshMineFeeEstimate(), + this.refreshTransferFeeEstimate(), + ]); + }, + + async refreshBurnFeeEstimate() { + const amount = this.parseiunaAmount(this.burnAmountDraft); + const feePerByte = this.parseiunaAmount(this.burnFeeDraft); + if (amount <= 0) { + this.feeEstimates.burn = null; + return; + } + this.feeEstimates.burn = await this.fetchFeeEstimate("/api/fee-estimate/burn", { + amount, + fee_per_byte: feePerByte, + }); + }, + + async refreshMineFeeEstimate() { + const feePerByte = this.parseiunaAmount(this.powMineFeeDraft); + this.feeEstimates.mine = await this.fetchFeeEstimate("/api/fee-estimate/mine", { + enabled: this.powMiningEnabled, + fee_per_byte: feePerByte, + }); + }, + + async refreshTransferFeeEstimate() { + const amount = this.parseiunaAmount(this.transferAmount); + const feePerByte = this.parseiunaAmount(this.transferFee); + if (!this.transferTo.trim() || amount <= 0) { + this.feeEstimates.transfer = null; + return; + } + this.feeEstimates.transfer = await this.fetchFeeEstimate("/api/fee-estimate/transfer", { + to: this.transferTo, + amount, + fee_per_byte: feePerByte, + utxos: this.selectedTransferUtxos.join("\n"), + }); + }, + + async fetchFeeEstimate(path, fields) { + try { + 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) { + return { error: payload.error || `${path} returned ${response.status}` }; + } + return payload; + } catch (error) { + return { error: error.message }; + } + }, + + feeEstimateLabel(kind) { + const estimate = this.feeEstimates[kind]; + if (!estimate) return "Enter details to estimate fee"; + if (estimate.error) return estimate.error; + return `${estimate.bytes} bytes -> IUNA ${this.amountLabel(estimate.fee)}`; + }, + + async saveBurn() { + try { + const amount = this.parseiunaAmount(this.burnAmountDraft); + const fee = this.parseiunaAmountRequired(this.burnFeeDraft, "Burn fee per byte is required"); + this.burnAmountDraft = this.amountLabel(amount); + this.burnFeeDraft = this.amountLabel(fee); + await this.postForm( + "/api/settings/burn-per-block", + { enabled: this.miningEnabled, amount, fee_per_byte: fee }, + this.miningEnabled + ? `Finalization burns on: ${this.amountLabel(amount)} IUNA per block with ${this.amountLabel(fee)} per byte` + : `Burn settings saved while off` + ); + this.burnAmountDirty = false; + this.burnAmount = amount; + this.burnFee = fee; + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + async setMiningEnabled(enabled) { + const previous = this.miningEnabled; + try { + const amount = this.parseiunaAmount(this.burnAmountDraft); + const fee = this.parseiunaAmountRequired(this.burnFeeDraft, "Burn fee per byte is required"); + if (enabled && amount === 0) { + this.miningEnabled = false; + throw new Error("Set IUNA per block before turning finalization burns on"); + } + this.miningEnabled = enabled; + await this.postForm( + "/api/settings/burn-per-block", + { enabled, amount, fee_per_byte: fee }, + enabled ? "Finalization burns turned on" : "Finalization burns turned off" + ); + this.burnAmountDirty = false; + this.burnAmount = amount; + this.burnFee = fee; + } catch (error) { + this.miningEnabled = previous; + this.showFlash(error.message, "error"); + } + }, + + async setPowMiningEnabled(enabled) { + const previous = this.powMiningEnabled; + try { + const fee = this.parseiunaAmountRequired(this.powMineFeeDraft, "Mine fee per byte is required"); + this.powMiningEnabled = enabled; + await this.postForm( + "/api/settings/pow-mining", + { enabled, fee_per_byte: fee }, + enabled ? "PoW mining turned on" : "PoW mining turned off" + ); + this.powMineFeeDirty = false; + this.powMineFee = fee; + } catch (error) { + this.powMiningEnabled = previous; + this.showFlash(error.message, "error"); + } + }, + + async savePowMining() { + try { + const fee = this.parseiunaAmountRequired(this.powMineFeeDraft, "Mine fee per byte is required"); + this.powMineFeeDraft = this.amountLabel(fee); + await this.postForm( + "/api/settings/pow-mining", + { enabled: this.powMiningEnabled, fee_per_byte: fee }, + this.powMiningEnabled + ? `Mine fee rate set to ${this.amountLabel(fee)} IUNA per byte` + : `Mine settings saved while off` + ); + this.powMineFeeDirty = false; + this.powMineFee = fee; + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + automaticBurnFeeDraft() { + return this.parseiunaAmount(this.burnFeeDraft); + }, + + powMineFeeValue() { + try { + return this.parseiunaAmount(this.powMineFeeDraft); + } catch { + return this.powMineFee; + } + }, + + powMineNetReward() { + const reward = Math.max(0, Math.trunc(Number(this.status.chain?.mine_reward ?? 0))); + return Math.max(0, reward - (this.feeEstimates.mine?.fee ?? this.powMineFeeValue())); + }, + + amountLabel(value) { + const microiuna = Math.max(0, Math.trunc(Number(value) || 0)); + const whole = Math.floor(microiuna / 1000000); + const fractional = String(microiuna % 1000000).padStart(6, "0").replace(/0+$/, ""); + return fractional ? `${whole}.${fractional}` : `${whole}`; + }, + + amountNumber(value) { + return Number(this.amountLabel(value)); + }, + + parseiunaAmount(value) { + const text = String(value ?? "").trim(); + if (!text) return 0; + const match = text.match(/^(\d+)(?:\.(\d{0,6})\d*)?$/); + if (!match) return 0; + const whole = Number(match[1] || 0); + const fractional = Number((match[2] || "").padEnd(6, "0")); + return Math.max(0, Math.trunc(whole * 1000000 + fractional)); + }, + + parseiunaAmountRequired(value, message) { + const text = String(value ?? "").trim(); + if (!text) throw new Error(message); + const parsed = this.parseiunaAmount(text); + if (parsed === 0 && !/^0(?:\.0*)?$/.test(text)) throw new Error(message); + return parsed; + }, + + async sendTransfer() { + try { + const amount = this.parseiunaAmount(this.transferAmount); + const fee = this.parseiunaAmountRequired(this.transferFee, "Transfer fee per byte is required"); + const recipient = this.short(this.transferTo); + await this.postForm( + "/api/transfer", + { to: this.transferTo, amount, fee_per_byte: fee, utxos: this.selectedTransferUtxos.join("\n") }, + `Queued transfer of ${this.amountLabel(amount)} IUNA to ${recipient}` + ); + this.transferTo = ""; + this.transferAmount = null; + this.selectedTransferUtxos = []; + this.showSendAdvanced = false; + this.feeEstimates.transfer = null; + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + toggleSendAdvanced() { + this.showSendAdvanced = !this.showSendAdvanced; + if (!this.showSendAdvanced) { + this.selectedTransferUtxos = []; + } + }, + + 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 }; + }, + + showAuthFeedback(message, kind) { + this.authFeedback = { message, kind }; + }, + + short(value) { + if (!value) return "-"; + if (value.length <= 16) return value; + return `${value.slice(0, 8)}...${value.slice(-8)}`; + }, + + txFrom(tx) { + return tx.from ?? tx.inputs?.[0]?.owner ?? ""; + }, + + txTo(tx) { + return tx.to ?? tx.outputs?.[0]?.address ?? null; + }, + + txAmount(tx) { + return tx.amount ?? tx.outputs?.[0]?.amount ?? 0; + }, + + isMineTx(tx) { + return tx?.kind === "mine"; + }, + + txDifficultyBits(tx) { + return tx?.difficulty_bits ?? tx?.difficultyBits ?? null; + }, + + txProofBits(tx) { + return tx?.proof_bits ?? tx?.proofBits ?? null; + }, + + txProofHash(tx) { + return tx?.proof_hash ?? tx?.proofHash ?? tx?.signature ?? null; + }, + + txInputs(tx) { + return Array.isArray(tx.inputs) ? tx.inputs : []; + }, + + txVisualOutputs(tx) { + const rows = []; + if (tx.kind === "burn" && Number(tx.amount || 0) > 0) { + rows.push({ + kind: "burned", + label: "Burn", + amount: tx.amount, + address: null, + }); + } + if (Number(tx.fee || 0) > 0) { + rows.push({ + kind: "fee", + label: "Fee", + amount: tx.fee, + address: null, + detailLabel: "To", + detail: this.txFeeRecipient(tx), + }); + } + const directOutputs = Array.isArray(tx.outputs) ? tx.outputs : []; + for (const [index, output] of directOutputs.entries()) { + rows.push({ + kind: "output", + label: `Output ${index + 1}`, + amount: output.amount, + address: output.address, + }); + } + const changeOutputs = Array.isArray(tx.change) ? tx.change : []; + for (const [index, output] of changeOutputs.entries()) { + rows.push({ + kind: "change", + label: `Change ${index + 1}`, + amount: output.amount, + address: output.address, + }); + } + return rows; + }, + + txInputKey(input, index) { + return `${input.outpoint?.txid || "input"}:${input.outpoint?.index ?? index}`; + }, + + txOutputKey(output, index) { + return `${output.kind}:${output.address || output.kind}:${output.amount}:${index}`; + }, + + txInputOutpoint(input) { + const txid = input.outpoint?.txid || "-"; + const index = input.outpoint?.index ?? "-"; + return `${txid}:${index}`; + }, + + utxoOutpoint(utxo) { + return this.txInputOutpoint({ outpoint: utxo.outpoint }); + }, + + selectAllTransferUtxos() { + this.selectedTransferUtxos = this.walletUtxos.map((utxo) => this.utxoOutpoint(utxo)); + this.scheduleFeeEstimates(); + }, + + clearTransferUtxos() { + this.selectedTransferUtxos = []; + this.scheduleFeeEstimates(); + }, + + selectedTransferUtxoTotal() { + const selected = new Set(this.selectedTransferUtxos); + return this.walletUtxos + .filter((utxo) => selected.has(this.utxoOutpoint(utxo))) + .reduce((sum, utxo) => sum + Number(utxo.amount || 0), 0); + }, + + transferRequiredTotal() { + return this.parseiunaAmount(this.transferAmount) + Number(this.feeEstimates.transfer?.fee || 0); + }, + + selectedTransferUtxosCoverTransfer() { + return this.selectedTransferUtxos.length === 0 || this.selectedTransferUtxoTotal() >= this.transferRequiredTotal(); + }, + + txInputAmountLabel(input) { + return input.amount === null || input.amount === undefined ? "-" : `IUNA ${this.amountLabel(input.amount)}`; + }, + + txFeeRecipient(tx) { + const context = this.selectedTransaction?.context || {}; + return tx.blockFinalizer ?? tx.blockMiner ?? context.blockFinalizer ?? context.blockMiner ?? "future block finalizer"; + }, + + selectedTransactionLabel() { + if (!this.selectedTransaction) return "-"; + const { tx, context } = this.selectedTransaction; + if (context.blockHeight !== undefined) return `Block ${context.blockHeight}`; + if (tx?.status === "pending") return "Wallet pending"; + if (tx?.blockHeight !== null && tx?.blockHeight !== undefined) { + return `Wallet block ${tx.blockHeight}`; + } + return context.source || "-"; + }, + + blockBurned(block) { + return block.transactions + .filter((tx) => tx.kind === "burn") + .reduce((sum, tx) => sum + this.txAmount(tx), 0); + }, + + blockTotalFees(block) { + const explicitTotal = block?.totalFees ?? block?.total_fees ?? block?.reward; + if (explicitTotal !== null && explicitTotal !== undefined) return Number(explicitTotal) || 0; + return (block?.transactions || []).reduce((sum, tx) => sum + Number(tx.fee || 0), 0); + }, + + recentBlockFeeAverage(count) { + const sample = this.blocks.filter((block) => block.height > 0).slice(0, count); + if (sample.length === 0) return 0; + return Math.round(sample.reduce((sum, block) => sum + this.blockTotalFees(block), 0) / sample.length); + }, + + blockBurnCount(block) { + return block.transactions.filter((tx) => tx.kind === "burn").length; + }, + + blockTransferCount(block) { + return block.transactions.filter((tx) => tx.kind === "transfer").length; + }, + + blockMineCount(block) { + return block.transactions.filter((tx) => tx.kind === "mine").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"}`; + }, + + mineCountLabel(block) { + const count = this.blockMineCount(block); + return `${count} mine${count === 1 ? "" : "s"}`; + }, + + blockFinalizerLabel(block) { + const finalizer = this.short(block.miner); + return block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer; + }, + + walletTransactions() { + return this.walletTxs; + }, + + 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` : "-"; + }, + + stratumListenAddr() { + return this.status.stratum?.listen_addr || "-"; + }, + + stratumPoolUrl() { + const listen = this.status.stratum?.listen_addr; + if (!this.status.stratum?.enabled || !listen) return "-"; + const lastColon = listen.lastIndexOf(":"); + if (lastColon < 0) return `stratum+tcp://${listen}`; + let host = listen.slice(0, lastColon); + const port = listen.slice(lastColon + 1); + if (host === "0.0.0.0" || host === "::" || host === "[::]") { + host = window.location.hostname || "127.0.0.1"; + } + return `stratum+tcp://${host}:${port}`; + }, + + lastUpdatedLabel() { + return this.lastUpdated ? `Updated ${this.lastUpdated.toLocaleTimeString()}` : "Loading"; + }, + }; +}; diff --git a/assets/luun-ui.js b/assets/luun-ui.js @@ -1,1080 +0,0 @@ -window.luunApp = function luunApp() { - return { - tab: "wallet", - status: {}, - blocks: [], - selectedBlock: null, - selectedTransaction: null, - loadingOlder: false, - hasMoreBlocks: true, - walletTxs: [], - walletUtxos: [], - mempool: [], - peers: [], - p2pMetrics: {}, - config: { setup_complete: false }, - auth: { configured: false, authenticated: false }, - authLoaded: false, - authPassword: "", - authPasswordConfirm: "", - loginPassword: "", - authFeedback: null, - 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, - burnFee: 1, - burnFeeDraft: "0.000001", - miningEnabled: false, - powMiningEnabled: false, - powMineFee: 1, - powMineFeeDraft: "0.000001", - powMineFeeDirty: false, - burnAmountDirty: false, - transferTo: "", - transferAmount: null, - transferFee: "0.000001", - feeEstimates: { transfer: null, burn: null, mine: null }, - feeEstimateTimer: null, - showSendAdvanced: false, - selectedTransferUtxos: [], - peerAddress: "", - flash: null, - flashTimer: null, - showWalletUtxos: false, - showPowDifficultyInfo: false, - lastUpdated: null, - pollHandle: null, - hashListenerInstalled: false, - newBlockHashes: new Set(), - newBlockTimer: null, - blockPageSize: 20, - - init() { - this.bootstrap(); - }, - - async bootstrap() { - await this.refreshAuth(); - if (this.showingAuth()) return; - await this.bootstrapAuthenticated(); - }, - - async bootstrapAuthenticated() { - await this.refreshConfig(); - if (!this.config.setup_complete) { - await this.refreshWalletSetup(); - } - this.tab = this.tabFromHash(); - if (!this.hashListenerInstalled) { - window.addEventListener("hashchange", () => { - this.tab = this.tabFromHash(); - }); - this.hashListenerInstalled = true; - } - await this.refresh(); - if (!this.pollHandle) { - 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.authLoaded && !this.showingAuth() && !this.config.setup_complete; - }, - - showingAuth() { - return this.authLoaded && (!this.auth.configured || !this.auth.authenticated); - }, - - async refreshAuth() { - this.auth = await this.fetchJson("/api/auth/status"); - this.authLoaded = true; - }, - - async setupPassword() { - try { - this.authFeedback = null; - if (this.authPassword !== this.authPasswordConfirm) { - throw new Error("Passwords do not match"); - } - await this.postAuth("/api/auth/setup", this.authPassword); - this.authPassword = ""; - this.authPasswordConfirm = ""; - await this.refreshAuth(); - await this.bootstrapAuthenticated(); - this.showFlash("Password set", "success"); - } catch (error) { - this.showAuthFeedback(error.message, "error"); - } - }, - - async login() { - try { - this.authFeedback = null; - await this.postAuth("/api/auth/login", this.loginPassword); - this.loginPassword = ""; - await this.refreshAuth(); - await this.bootstrapAuthenticated(); - this.showFlash("Logged in", "success"); - } catch (error) { - this.showAuthFeedback(error.message, "error"); - } - }, - - async postAuth(path, password) { - const response = await fetch(path, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ password }), - }); - const text = await response.text(); - let payload = { ok: response.ok, error: null }; - if (text) { - try { - payload = JSON.parse(text); - } catch { - payload = { ok: false, error: text }; - } - } - if (!response.ok || !payload.ok) { - throw new Error(payload.error || `${path} returned ${response.status}`); - } - return payload; - }, - - async logout() { - try { - await this.postAuth("/api/auth/logout", ""); - await this.refreshAuth(); - this.showFlash("Locked", "success"); - } catch (error) { - this.showFlash(error.message, "error"); - } - }, - - 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, walletTxs, walletUtxos, mempool, peers, p2pMetrics] = await Promise.all([ - this.fetchJson("/api/config"), - this.fetchJson("/api/status"), - this.fetchJson("/api/blocks?limit=30"), - this.fetchJson("/api/wallet/transactions"), - this.fetchJson("/api/wallet/utxos"), - this.fetchJson("/api/mempool"), - this.fetchJson("/api/peers"), - this.fetchJson("/api/p2p/metrics"), - ]); - this.config = config; - if (!this.config.setup_complete) { - await this.refreshWalletSetup(); - } - this.status = status; - this.mergeFreshBlocks(blocks, { animateHead: true }); - this.walletTxs = walletTxs; - this.walletUtxos = walletUtxos; - this.mempool = mempool; - this.peers = peers; - this.p2pMetrics = p2pMetrics; - this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; - this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; - this.miningEnabled = status.mining?.automatic ?? this.miningEnabled; - this.powMiningEnabled = status.mining?.pow_mining_enabled ?? this.powMiningEnabled; - this.powMineFee = status.mining?.automatic_pow_mine_fee ?? this.powMineFee; - if (!this.burnAmountDirty) { - this.burnAmountDraft = this.amountLabel(this.burnAmount); - this.burnFeeDraft = this.amountLabel(this.burnFee); - } - if (!this.powMineFeeDirty) { - this.powMineFeeDraft = this.amountLabel(this.powMineFee); - } - this.lastUpdated = new Date(); - this.scheduleFeeEstimates(); - } catch (error) { - if (String(error.message || "").includes("401")) { - await this.refreshAuth(); - return; - } - 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; - }, - - openTransactionModal(tx, context = {}) { - this.selectedTransaction = { tx, context }; - }, - - closeTransactionModal() { - this.selectedTransaction = null; - }, - - openWalletUtxosModal() { - this.showWalletUtxos = true; - }, - - closeWalletUtxosModal() { - this.showWalletUtxos = false; - }, - - openPowDifficultyInfo() { - this.showPowDifficultyInfo = true; - }, - - closePowDifficultyInfo() { - this.showPowDifficultyInfo = false; - }, - - closeModals() { - this.closeTransactionModal(); - this.closeWalletUtxosModal(); - this.closePowDifficultyInfo(); - }, - - 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)) { - if (Array.isArray(value)) { - for (const item of value) body.append(key, item); - } else { - body.set(key, value); - } - } - const response = await fetch(path, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, - body, - }); - const text = await response.text(); - let payload = { ok: response.ok, error: null }; - if (text) { - try { - payload = JSON.parse(text); - } catch { - payload = { ok: false, error: text }; - } - } - if (!response.ok || !payload.ok) { - throw new Error(payload?.error || `${path} returned ${response.status}`); - } - await this.refresh(); - this.showFlash(successMessage, "success"); - }, - - scheduleFeeEstimates() { - if (this.feeEstimateTimer) clearTimeout(this.feeEstimateTimer); - this.feeEstimateTimer = setTimeout(() => this.refreshFeeEstimates(), 220); - }, - - async refreshFeeEstimates() { - if (this.showingAuth()) return; - await Promise.all([ - this.refreshBurnFeeEstimate(), - this.refreshMineFeeEstimate(), - this.refreshTransferFeeEstimate(), - ]); - }, - - async refreshBurnFeeEstimate() { - const amount = this.parseLuunAmount(this.burnAmountDraft); - const feePerByte = this.parseLuunAmount(this.burnFeeDraft); - if (amount <= 0) { - this.feeEstimates.burn = null; - return; - } - this.feeEstimates.burn = await this.fetchFeeEstimate("/api/fee-estimate/burn", { - amount, - fee_per_byte: feePerByte, - }); - }, - - async refreshMineFeeEstimate() { - const feePerByte = this.parseLuunAmount(this.powMineFeeDraft); - this.feeEstimates.mine = await this.fetchFeeEstimate("/api/fee-estimate/mine", { - enabled: this.powMiningEnabled, - fee_per_byte: feePerByte, - }); - }, - - async refreshTransferFeeEstimate() { - const amount = this.parseLuunAmount(this.transferAmount); - const feePerByte = this.parseLuunAmount(this.transferFee); - if (!this.transferTo.trim() || amount <= 0) { - this.feeEstimates.transfer = null; - return; - } - this.feeEstimates.transfer = await this.fetchFeeEstimate("/api/fee-estimate/transfer", { - to: this.transferTo, - amount, - fee_per_byte: feePerByte, - utxos: this.selectedTransferUtxos.join("\n"), - }); - }, - - async fetchFeeEstimate(path, fields) { - try { - 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) { - return { error: payload.error || `${path} returned ${response.status}` }; - } - return payload; - } catch (error) { - return { error: error.message }; - } - }, - - feeEstimateLabel(kind) { - const estimate = this.feeEstimates[kind]; - if (!estimate) return "Enter details to estimate fee"; - if (estimate.error) return estimate.error; - return `${estimate.bytes} bytes -> LUUN ${this.amountLabel(estimate.fee)}`; - }, - - async saveBurn() { - try { - const amount = this.parseLuunAmount(this.burnAmountDraft); - const fee = this.parseLuunAmountRequired(this.burnFeeDraft, "Burn fee per byte is required"); - this.burnAmountDraft = this.amountLabel(amount); - this.burnFeeDraft = this.amountLabel(fee); - await this.postForm( - "/api/settings/burn-per-block", - { enabled: this.miningEnabled, amount, fee_per_byte: fee }, - this.miningEnabled - ? `Finalization burns on: ${this.amountLabel(amount)} LUUN per block with ${this.amountLabel(fee)} per byte` - : `Burn settings saved while off` - ); - this.burnAmountDirty = false; - this.burnAmount = amount; - this.burnFee = fee; - } catch (error) { - this.showFlash(error.message, "error"); - } - }, - - async setMiningEnabled(enabled) { - const previous = this.miningEnabled; - try { - const amount = this.parseLuunAmount(this.burnAmountDraft); - const fee = this.parseLuunAmountRequired(this.burnFeeDraft, "Burn fee per byte is required"); - if (enabled && amount === 0) { - this.miningEnabled = false; - throw new Error("Set LUUN per block before turning finalization burns on"); - } - this.miningEnabled = enabled; - await this.postForm( - "/api/settings/burn-per-block", - { enabled, amount, fee_per_byte: fee }, - enabled ? "Finalization burns turned on" : "Finalization burns turned off" - ); - this.burnAmountDirty = false; - this.burnAmount = amount; - this.burnFee = fee; - } catch (error) { - this.miningEnabled = previous; - this.showFlash(error.message, "error"); - } - }, - - async setPowMiningEnabled(enabled) { - const previous = this.powMiningEnabled; - try { - const fee = this.parseLuunAmountRequired(this.powMineFeeDraft, "Mine fee per byte is required"); - this.powMiningEnabled = enabled; - await this.postForm( - "/api/settings/pow-mining", - { enabled, fee_per_byte: fee }, - enabled ? "PoW mining turned on" : "PoW mining turned off" - ); - this.powMineFeeDirty = false; - this.powMineFee = fee; - } catch (error) { - this.powMiningEnabled = previous; - this.showFlash(error.message, "error"); - } - }, - - async savePowMining() { - try { - const fee = this.parseLuunAmountRequired(this.powMineFeeDraft, "Mine fee per byte is required"); - this.powMineFeeDraft = this.amountLabel(fee); - await this.postForm( - "/api/settings/pow-mining", - { enabled: this.powMiningEnabled, fee_per_byte: fee }, - this.powMiningEnabled - ? `Mine fee rate set to ${this.amountLabel(fee)} LUUN per byte` - : `Mine settings saved while off` - ); - this.powMineFeeDirty = false; - this.powMineFee = fee; - } catch (error) { - this.showFlash(error.message, "error"); - } - }, - - automaticBurnFeeDraft() { - return this.parseLuunAmount(this.burnFeeDraft); - }, - - powMineFeeValue() { - try { - return this.parseLuunAmount(this.powMineFeeDraft); - } catch { - return this.powMineFee; - } - }, - - powMineNetReward() { - const reward = Math.max(0, Math.trunc(Number(this.status.chain?.mine_reward ?? 0))); - return Math.max(0, reward - (this.feeEstimates.mine?.fee ?? this.powMineFeeValue())); - }, - - amountLabel(value) { - const microluun = Math.max(0, Math.trunc(Number(value) || 0)); - const whole = Math.floor(microluun / 1000000); - const fractional = String(microluun % 1000000).padStart(6, "0").replace(/0+$/, ""); - return fractional ? `${whole}.${fractional}` : `${whole}`; - }, - - amountNumber(value) { - return Number(this.amountLabel(value)); - }, - - parseLuunAmount(value) { - const text = String(value ?? "").trim(); - if (!text) return 0; - const match = text.match(/^(\d+)(?:\.(\d{0,6})\d*)?$/); - if (!match) return 0; - const whole = Number(match[1] || 0); - const fractional = Number((match[2] || "").padEnd(6, "0")); - return Math.max(0, Math.trunc(whole * 1000000 + fractional)); - }, - - parseLuunAmountRequired(value, message) { - const text = String(value ?? "").trim(); - if (!text) throw new Error(message); - const parsed = this.parseLuunAmount(text); - if (parsed === 0 && !/^0(?:\.0*)?$/.test(text)) throw new Error(message); - return parsed; - }, - - async sendTransfer() { - try { - const amount = this.parseLuunAmount(this.transferAmount); - const fee = this.parseLuunAmountRequired(this.transferFee, "Transfer fee per byte is required"); - const recipient = this.short(this.transferTo); - await this.postForm( - "/api/transfer", - { to: this.transferTo, amount, fee_per_byte: fee, utxos: this.selectedTransferUtxos.join("\n") }, - `Queued transfer of ${this.amountLabel(amount)} LUUN to ${recipient}` - ); - this.transferTo = ""; - this.transferAmount = null; - this.selectedTransferUtxos = []; - this.showSendAdvanced = false; - this.feeEstimates.transfer = null; - } catch (error) { - this.showFlash(error.message, "error"); - } - }, - - toggleSendAdvanced() { - this.showSendAdvanced = !this.showSendAdvanced; - if (!this.showSendAdvanced) { - this.selectedTransferUtxos = []; - } - }, - - 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 }; - }, - - showAuthFeedback(message, kind) { - this.authFeedback = { message, kind }; - }, - - short(value) { - if (!value) return "-"; - if (value.length <= 16) return value; - return `${value.slice(0, 8)}...${value.slice(-8)}`; - }, - - txFrom(tx) { - return tx.from ?? tx.inputs?.[0]?.owner ?? ""; - }, - - txTo(tx) { - return tx.to ?? tx.outputs?.[0]?.address ?? null; - }, - - txAmount(tx) { - return tx.amount ?? tx.outputs?.[0]?.amount ?? 0; - }, - - isMineTx(tx) { - return tx?.kind === "mine"; - }, - - txDifficultyBits(tx) { - return tx?.difficulty_bits ?? tx?.difficultyBits ?? null; - }, - - txProofBits(tx) { - return tx?.proof_bits ?? tx?.proofBits ?? null; - }, - - txProofHash(tx) { - return tx?.proof_hash ?? tx?.proofHash ?? tx?.signature ?? null; - }, - - txInputs(tx) { - return Array.isArray(tx.inputs) ? tx.inputs : []; - }, - - txVisualOutputs(tx) { - const rows = []; - if (tx.kind === "burn" && Number(tx.amount || 0) > 0) { - rows.push({ - kind: "burned", - label: "Burn", - amount: tx.amount, - address: null, - }); - } - if (Number(tx.fee || 0) > 0) { - rows.push({ - kind: "fee", - label: "Fee", - amount: tx.fee, - address: null, - detailLabel: "To", - detail: this.txFeeRecipient(tx), - }); - } - const directOutputs = Array.isArray(tx.outputs) ? tx.outputs : []; - for (const [index, output] of directOutputs.entries()) { - rows.push({ - kind: "output", - label: `Output ${index + 1}`, - amount: output.amount, - address: output.address, - }); - } - const changeOutputs = Array.isArray(tx.change) ? tx.change : []; - for (const [index, output] of changeOutputs.entries()) { - rows.push({ - kind: "change", - label: `Change ${index + 1}`, - amount: output.amount, - address: output.address, - }); - } - return rows; - }, - - txInputKey(input, index) { - return `${input.outpoint?.txid || "input"}:${input.outpoint?.index ?? index}`; - }, - - txOutputKey(output, index) { - return `${output.kind}:${output.address || output.kind}:${output.amount}:${index}`; - }, - - txInputOutpoint(input) { - const txid = input.outpoint?.txid || "-"; - const index = input.outpoint?.index ?? "-"; - return `${txid}:${index}`; - }, - - utxoOutpoint(utxo) { - return this.txInputOutpoint({ outpoint: utxo.outpoint }); - }, - - selectAllTransferUtxos() { - this.selectedTransferUtxos = this.walletUtxos.map((utxo) => this.utxoOutpoint(utxo)); - this.scheduleFeeEstimates(); - }, - - clearTransferUtxos() { - this.selectedTransferUtxos = []; - this.scheduleFeeEstimates(); - }, - - selectedTransferUtxoTotal() { - const selected = new Set(this.selectedTransferUtxos); - return this.walletUtxos - .filter((utxo) => selected.has(this.utxoOutpoint(utxo))) - .reduce((sum, utxo) => sum + Number(utxo.amount || 0), 0); - }, - - transferRequiredTotal() { - return this.parseLuunAmount(this.transferAmount) + Number(this.feeEstimates.transfer?.fee || 0); - }, - - selectedTransferUtxosCoverTransfer() { - return this.selectedTransferUtxos.length === 0 || this.selectedTransferUtxoTotal() >= this.transferRequiredTotal(); - }, - - txInputAmountLabel(input) { - return input.amount === null || input.amount === undefined ? "-" : `LUUN ${this.amountLabel(input.amount)}`; - }, - - txFeeRecipient(tx) { - const context = this.selectedTransaction?.context || {}; - return tx.blockFinalizer ?? tx.blockMiner ?? context.blockFinalizer ?? context.blockMiner ?? "future block finalizer"; - }, - - selectedTransactionLabel() { - if (!this.selectedTransaction) return "-"; - const { tx, context } = this.selectedTransaction; - if (context.blockHeight !== undefined) return `Block ${context.blockHeight}`; - if (tx?.status === "pending") return "Wallet pending"; - if (tx?.blockHeight !== null && tx?.blockHeight !== undefined) { - return `Wallet block ${tx.blockHeight}`; - } - return context.source || "-"; - }, - - blockBurned(block) { - return block.transactions - .filter((tx) => tx.kind === "burn") - .reduce((sum, tx) => sum + this.txAmount(tx), 0); - }, - - blockTotalFees(block) { - const explicitTotal = block?.totalFees ?? block?.total_fees ?? block?.reward; - if (explicitTotal !== null && explicitTotal !== undefined) return Number(explicitTotal) || 0; - return (block?.transactions || []).reduce((sum, tx) => sum + Number(tx.fee || 0), 0); - }, - - recentBlockFeeAverage(count) { - const sample = this.blocks.filter((block) => block.height > 0).slice(0, count); - if (sample.length === 0) return 0; - return Math.round(sample.reduce((sum, block) => sum + this.blockTotalFees(block), 0) / sample.length); - }, - - blockBurnCount(block) { - return block.transactions.filter((tx) => tx.kind === "burn").length; - }, - - blockTransferCount(block) { - return block.transactions.filter((tx) => tx.kind === "transfer").length; - }, - - blockMineCount(block) { - return block.transactions.filter((tx) => tx.kind === "mine").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"}`; - }, - - mineCountLabel(block) { - const count = this.blockMineCount(block); - return `${count} mine${count === 1 ? "" : "s"}`; - }, - - blockFinalizerLabel(block) { - const finalizer = this.short(block.miner); - return block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer; - }, - - walletTransactions() { - return this.walletTxs; - }, - - 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` : "-"; - }, - - stratumListenAddr() { - return this.status.stratum?.listen_addr || "-"; - }, - - stratumPoolUrl() { - const listen = this.status.stratum?.listen_addr; - if (!this.status.stratum?.enabled || !listen) return "-"; - const lastColon = listen.lastIndexOf(":"); - if (lastColon < 0) return `stratum+tcp://${listen}`; - let host = listen.slice(0, lastColon); - const port = listen.slice(lastColon + 1); - if (host === "0.0.0.0" || host === "::" || host === "[::]") { - host = window.location.hostname || "127.0.0.1"; - } - return `stratum+tcp://${host}:${port}`; - }, - - 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 -Luun starts from a running node first, then lets the explanation grow around the code. +iuna 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 Luun 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 iuna feel alive as quickly as possible. -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 important design choice is the hexagonal split. The iuna rules live in the domain layer. The TCP server and HTTP UI sit outside that. Because of that, tests can run a little iuna network entirely in memory, without ports, sleeps, containers, or a pretend deployment. The consensus sketch is intentionally small: -- burn LUUN into a block, +- burn IUNA into a block, - turn those burns into mature one-shot tickets for a short future block window, - 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 LUUN were already burned. +- forget the stake because the IUNA 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 Luun. +The first UI had a "mine next block" button. That was useful for proving the ledger worked, but it was the wrong feeling for iuna. 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 after a short maturity delay, then stay eligible for a small future block window. @@ -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 Luun idea now. +Still friendly-node land. Still deliberately simple. But the rhythm is closer to the actual iuna 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 LUUN yet. They can still follow the chain, receive LUUN, 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 IUNA yet. They can still follow the chain, receive IUNA, 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 Luun in genesis and burns it immediately, so their visible balance is 0, but the chain has bootstrap lottery tickets. Those tickets let the starter produce the first real reward blocks while normal burn tickets mature. +Genesis changed too. The starter does not begin rich anymore. The starter gets 1 synthetic iuna in genesis and burns it immediately, so their visible balance is 0, but the chain has bootstrap lottery tickets. Those tickets let the starter produce the first real reward blocks while normal burn tickets mature. -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. +This is still not real adversarial sync. It trusts the friend you join. But for the current iuna 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`, 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. +On first real startup, `--start` or `--join`, iuna creates a wallet file and reuses it next time. The default is `.iuna/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 LUUN 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 IUNA 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 Luun 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 iuna 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 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. +This is still intentionally small. It is not trying to be libp2p. But it is much closer to how the iuna 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, 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. +Until now, iuna 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 Luun. 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 iuna. 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/docs/genesis.md b/docs/genesis.md @@ -1,6 +1,6 @@ # Starting The Genesis Node -This is operator documentation for bootstrapping a Luun devnet. Most users should join an existing seed node instead. +This is operator documentation for bootstrapping a iuna devnet. Most users should join an existing seed node instead. ## Start Setup @@ -20,7 +20,7 @@ Restart from a fresh chain database with `--genesis`: cargo run -- --genesis --p2p 0.0.0.0:9444 --http 127.0.0.1:18661 ``` -Genesis bootstraps the chain with a 1 LUUN burn, creates launch tickets for the first blocks, measures an initial VDF delay, and leaves the starter wallet with spendable LUUN for early testing. +Genesis bootstraps the chain with a 1 IUNA burn, creates launch tickets for the first blocks, measures an initial VDF delay, and leaves the starter wallet with spendable IUNA for early testing. ## Invite Nodes @@ -33,7 +33,7 @@ your-host.example:9444 They can join with: ```sh -cargo run -- --data-dir .luun --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host.example:9444 +cargo run -- --data-dir .iuna --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host.example:9444 ``` Keep the management UI bound to `127.0.0.1`. diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -9,10 +9,10 @@ use std::{ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -use crate::domain::{Amount, DEFAULT_FEE_PER_BYTE, MICRO_LUUN}; +use crate::domain::{Amount, DEFAULT_FEE_PER_BYTE, MICRO_IUNA}; const CONFIG_FILE_VERSION: u32 = 1; -const AMOUNT_UNIT_MICROLUUN: &str = "microluun"; +const AMOUNT_UNIT_MICROIUNA: &str = "microiuna"; pub const DEFAULT_BURN_FEE: Amount = DEFAULT_FEE_PER_BYTE; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -83,7 +83,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { let stored = ConfigFile { version: CONFIG_FILE_VERSION, - amount_unit: Some(AMOUNT_UNIT_MICROLUUN.to_string()), + amount_unit: Some(AMOUNT_UNIT_MICROIUNA.to_string()), setup_complete: config.setup_complete, auth_password_hash: config.auth_password_hash.clone(), mining_enabled: Some(config.mining_enabled), @@ -116,10 +116,10 @@ fn load(path: &Path) -> Result<UiConfig> { ); } - let scale = if stored.amount_unit.as_deref() == Some(AMOUNT_UNIT_MICROLUUN) { + let scale = if stored.amount_unit.as_deref() == Some(AMOUNT_UNIT_MICROIUNA) { 1 } else { - MICRO_LUUN + MICRO_IUNA }; Ok(UiConfig { @@ -156,7 +156,7 @@ mod tests { use tempfile::tempdir; - use crate::domain::MICRO_LUUN; + use crate::domain::MICRO_IUNA; use super::{DEFAULT_BURN_FEE, DEFAULT_FEE_PER_BYTE, UiConfig, load_or_create, save}; @@ -170,7 +170,7 @@ mod tests { assert!(!config.setup_complete); let stored = fs::read_to_string(path).unwrap(); assert!(stored.contains("\"version\": 1")); - assert!(stored.contains("\"amount_unit\": \"microluun\"")); + assert!(stored.contains("\"amount_unit\": \"microiuna\"")); assert!(stored.contains("\"setup_complete\": false")); assert!(stored.contains("\"auth_password_hash\": null")); assert!(stored.contains("\"mining_enabled\": false")); @@ -193,9 +193,9 @@ mod tests { auth_password_hash: Some("auth-hash".to_string()), mining_enabled: true, pow_mining_enabled: true, - burn_per_block: 50 * MICRO_LUUN, - burn_fee: 3 * MICRO_LUUN, - pow_mine_fee: 2 * MICRO_LUUN, + burn_per_block: 50 * MICRO_IUNA, + burn_fee: 3 * MICRO_IUNA, + pow_mine_fee: 2 * MICRO_IUNA, peers: vec!["127.0.0.1:9444".to_string()], }, ) @@ -206,9 +206,9 @@ mod tests { assert_eq!(config.auth_password_hash.as_deref(), Some("auth-hash")); assert!(config.mining_enabled); assert!(config.pow_mining_enabled); - assert_eq!(config.burn_per_block, 50 * MICRO_LUUN); - assert_eq!(config.burn_fee, 3 * MICRO_LUUN); - assert_eq!(config.pow_mine_fee, 2 * MICRO_LUUN); + assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); + assert_eq!(config.burn_fee, 3 * MICRO_IUNA); + assert_eq!(config.pow_mine_fee, 2 * MICRO_IUNA); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); } @@ -270,8 +270,8 @@ mod tests { let config = load_or_create(&path).unwrap(); assert!(config.mining_enabled); - assert_eq!(config.burn_per_block, 2 * MICRO_LUUN); - assert_eq!(config.burn_fee, MICRO_LUUN); + assert_eq!(config.burn_per_block, 2 * MICRO_IUNA); + assert_eq!(config.burn_fee, MICRO_IUNA); assert_eq!(config.pow_mine_fee, DEFAULT_FEE_PER_BYTE); } } diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -34,7 +34,7 @@ use crate::{ const EXPLORER_LIMIT: usize = 50; const EXPLORER_PAGE_LIMIT: usize = 20; -const AUTH_COOKIE_NAME: &str = "luun_session"; +const AUTH_COOKIE_NAME: &str = "iuna_session"; const AUTH_SESSION_TTL_MS: u64 = 12 * 60 * 60 * 1_000; const PASSWORD_KDF_ALGORITHM: &str = "pbkdf2-sha256"; const PASSWORD_KDF_ITERATIONS: u32 = 120_000; @@ -233,7 +233,7 @@ pub async fn serve( let app = Router::new() .route("/", get(index)) .route("/assets/alpine.min.js", get(alpine_js)) - .route("/assets/luun-ui.js", get(app_js)) + .route("/assets/iuna-ui.js", get(app_js)) .route("/api/auth/status", get(api_auth_status)) .route("/api/auth/setup", post(api_auth_setup_form)) .route("/api/auth/login", post(api_auth_login_form)) @@ -298,7 +298,7 @@ async fn app_js() -> impl IntoResponse { header::CONTENT_TYPE, "application/javascript; charset=utf-8", )], - include_str!("../../assets/luun-ui.js"), + include_str!("../../assets/iuna-ui.js"), ) } @@ -325,7 +325,7 @@ async fn require_auth_middleware( fn auth_exempt_path(path: &str) -> bool { path == "/" || path == "/assets/alpine.min.js" - || path == "/assets/luun-ui.js" + || path == "/assets/iuna-ui.js" || path == "/api/auth/status" || path == "/api/auth/setup" || path == "/api/auth/login" @@ -1016,7 +1016,7 @@ fn index_transaction_outputs( fn genesis_allocation_outpoint(address: &str) -> OutPoint { OutPoint { - txid: hex_hash(format!("luun-genesis-allocation:{address}")), + txid: hex_hash(format!("iuna-genesis-allocation:{address}")), index: 0, } } @@ -1096,7 +1096,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("LUUN_DEV_SKIP_SEED_VERIFY").is_some()) + dev_seed_verify_bypass_allowed(std::env::var_os("IUNA_DEV_SKIP_SEED_VERIFY").is_some()) } fn dev_seed_verify_bypass_allowed(env_present: bool) -> bool { @@ -1299,7 +1299,7 @@ async fn create_session_cookie(state: &HttpState, password: &str) -> Result<Stri } fn session_token_hash(token: &str) -> String { - hex_encode(Sha256::digest(format!("luun-session:{token}").as_bytes())) + hex_encode(Sha256::digest(format!("iuna-session:{token}").as_bytes())) } fn auth_cookie(headers: &HeaderMap) -> Option<&str> { @@ -1457,7 +1457,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Luun</title> + <title>iuna</title> <style> [x-cloak] { display: none !important; } :root { @@ -1694,13 +1694,13 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/luun-ui.js?v=53"></script> + <script defer src="/assets/iuna-ui.js?v=53"></script> <script defer src="/assets/alpine.min.js"></script> </head> -<body x-data="luunApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> +<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> <div class="app-shell"> - <aside class="sidebar" aria-label="Luun navigation"> - <div class="brand-mark" title="Luun">L</div> + <aside class="sidebar" aria-label="iuna navigation"> + <div class="brand-mark" title="iuna">i</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> @@ -1724,7 +1724,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <main class="content"> <header> <div> - <h1 x-text="pageTitle()">Luun</h1> + <h1 x-text="pageTitle()">iuna</h1> </div> <div class="header-actions"> <div class="muted" x-text="lastUpdatedLabel()"></div> @@ -1738,7 +1738,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="page-title"> <button class="wallet-balance-line" type="button" @click="openWalletUtxosModal" title="Show wallet UTXOs"> <span class="tx-label">Balance</span> - <span class="tx-value money">LUUN <span x-text="amountLabel(status.wallet_balance)"></span></span> + <span class="tx-value money">IUNA <span x-text="amountLabel(status.wallet_balance)"></span></span> </button> </div> <div class="wallet-grid"> @@ -1753,8 +1753,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <button class="advanced-toggle" type="button" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide advanced' : 'Advanced'"></button> <div class="send-utxo-summary" x-show="showSendAdvanced"> <div>Selected UTXOs: <span x-text="selectedTransferUtxos.length"></span></div> - <div>Selected total: LUUN <span x-text="amountLabel(selectedTransferUtxoTotal())"></span></div> - <div>Required: LUUN <span x-text="amountLabel(transferRequiredTotal())"></span></div> + <div>Selected total: IUNA <span x-text="amountLabel(selectedTransferUtxoTotal())"></span></div> + <div>Required: IUNA <span x-text="amountLabel(transferRequiredTotal())"></span></div> <div class="setup-feedback error" x-show="!selectedTransferUtxosCoverTransfer()">Selected UTXOs do not cover amount plus fee</div> <div class="send-utxo-list"> <div class="send-utxo-list-head"> @@ -1768,7 +1768,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <label class="send-utxo-option"> <input type="checkbox" :value="utxoOutpoint(utxo)" x-model="selectedTransferUtxos" @change="scheduleFeeEstimates"> <span> - <span class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">LUUN <span x-text="amountLabel(utxo.amount)"></span></span></span> + <span class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">IUNA <span x-text="amountLabel(utxo.amount)"></span></span></span> <code class="tx-value hash" x-text="utxoOutpoint(utxo)"></code> </span> </label> @@ -1799,8 +1799,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="wallet-tx-row" :class="{ pending: tx.status === 'pending' }" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Wallet' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Wallet' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Wallet' })"> <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">LUUN <span x-text="amountLabel(tx.amount)"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(tx.amount)"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">IUNA <span x-text="amountLabel(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> @@ -1839,10 +1839,10 @@ const INDEX_HTML: &str = r#"<!doctype html> <span class="toggle-text" x-text="miningEnabled ? 'On' : 'Off'"></span> </label> </div> - <div class="panel-description">Burn LUUN to compete for block finalization. Winning burns finalize PoB/VDF blocks and earn the transaction fees in those blocks.</div> + <div class="panel-description">Burn IUNA to compete for block finalization. Winning burns finalize PoB/VDF blocks and earn the transaction fees in those blocks.</div> <form class="mining-form" @submit.prevent="saveBurn"> <div class="burn-fields"> - <label>LUUN per block<input x-model="burnAmountDraft" @input="burnAmountDirty = true; scheduleFeeEstimates()" type="number" min="0" step="0.000001"></label> + <label>IUNA per block<input x-model="burnAmountDraft" @input="burnAmountDirty = true; scheduleFeeEstimates()" type="number" min="0" step="0.000001"></label> <label>Fee / byte<input x-model="burnFeeDraft" @input="burnAmountDirty = true; scheduleFeeEstimates()" type="number" min="0" step="0.000001" required></label> <button class="primary" type="submit">Save</button> </div> @@ -1851,35 +1851,35 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="mine-stats fee-history" aria-label="Recent block fees"> <div class="mine-stat"> <div class="mine-stat-label">Last block fees</div> - <div class="mine-stat-value money">LUUN <span x-text="amountLabel(recentBlockFeeAverage(1))"></span></div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(1))"></span></div> </div> <div class="mine-stat"> <div class="mine-stat-label">5 block avg</div> - <div class="mine-stat-value money">LUUN <span x-text="amountLabel(recentBlockFeeAverage(5))"></span></div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(5))"></span></div> </div> <div class="mine-stat"> <div class="mine-stat-label">30 block avg</div> - <div class="mine-stat-value money">LUUN <span x-text="amountLabel(recentBlockFeeAverage(30))"></span></div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(30))"></span></div> </div> </div> </div> <div class="panel"> <h3>Mine</h3> - <div class="panel-description">Mine with PoW to introduce new LUUN. The miner chooses the fee paid to the block finalizer; the rest of the fixed mine reward goes to this wallet.</div> + <div class="panel-description">Mine with PoW to introduce new IUNA. The miner chooses the fee paid to the block finalizer; the rest of the fixed mine reward goes to this wallet.</div> <form class="mine-settings-form" @submit.prevent="savePowMining"> <div class="mine-action-row"> <div class="mine-stats" aria-label="PoW issuance settings"> <div class="mine-stat"> <div class="mine-stat-label">Total reward</div> - <div class="mine-stat-value money">LUUN <span x-text="amountLabel(status.chain?.mine_reward ?? 0)"></span></div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(status.chain?.mine_reward ?? 0)"></span></div> </div> <div class="mine-stat"> <div class="mine-stat-label">Finalizer fee</div> - <div class="mine-stat-value">LUUN <span x-text="amountLabel(feeEstimates.mine?.fee ?? 0)"></span></div> + <div class="mine-stat-value">IUNA <span x-text="amountLabel(feeEstimates.mine?.fee ?? 0)"></span></div> </div> <div class="mine-stat"> <div class="mine-stat-label">Miner receives</div> - <div class="mine-stat-value money">LUUN <span x-text="amountLabel(powMineNetReward())"></span></div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(powMineNetReward())"></span></div> </div> <div class="mine-stat"> <div class="mine-stat-label">Difficulty <button class="info-button" type="button" @click="openPowDifficultyInfo" title="How difficulty is adjusted" aria-label="How PoW difficulty is adjusted">i</button></div> @@ -2013,10 +2013,10 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div> <div class="detail-kv"><div class="key">Finalizer</div><code x-text="short(selectedBlock.miner)"></code></div> <div class="detail-kv"><div class="key">Rank</div><div x-text="selectedBlock.finalizer_rank ?? 0"></div></div> - <div class="detail-kv"><div class="key">Reward</div><div>LUUN <span x-text="amountLabel(selectedBlock.reward)"></span></div></div> + <div class="detail-kv"><div class="key">Reward</div><div>IUNA <span x-text="amountLabel(selectedBlock.reward)"></span></div></div> <div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div> <div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div> - <div class="detail-kv"><div class="key">Total Burned</div><div>LUUN <span x-text="amountLabel(blockBurned(selectedBlock))"></span></div></div> + <div class="detail-kv"><div class="key">Total Burned</div><div>IUNA <span x-text="amountLabel(blockBurned(selectedBlock))"></span></div></div> <div class="detail-kv"><div class="key">VDF</div><div><span x-text="selectedBlock.vdf_rounds"></span> rounds</div></div> </div> <div class="tx-list"> @@ -2024,8 +2024,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in selectedBlock.transactions" :key="tx.signature"> <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })"> <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">LUUN <span x-text="amountLabel(txAmount(tx))"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">IUNA <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(txFrom(tx))"></code></div> <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(txTo(tx))"></code></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> @@ -2046,8 +2046,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in mempool" :key="tx.signature"> <div class="mempool-item" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Mempool' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Mempool' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Mempool' })"> <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">LUUN <span x-text="amountLabel(txAmount(tx))"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">IUNA <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(txFrom(tx))"></code></div> <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(txTo(tx))"></code></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> @@ -2064,8 +2064,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="setup-overlay" x-show="showingAuth()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="auth-title"> <section class="setup-modal auth-form"> <div class="setup-modal-head"> - <div class="setup-welcome">Luun Access</div> - <h2 id="auth-title" x-text="auth.configured ? 'Unlock Luun' : 'Set Password'"></h2> + <div class="setup-welcome">iuna Access</div> + <h2 id="auth-title" x-text="auth.configured ? 'Unlock iuna' : 'Set Password'"></h2> <div class="setup-copy" x-show="!auth.configured">Choose a local password before wallet setup continues.</div> <div class="setup-copy" x-show="auth.configured">Enter the local password to unlock this node.</div> </div> @@ -2086,14 +2086,14 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-modal-head"> <div class="tx-modal-title"> <h2 id="wallet-utxos-title">Wallet UTXOs</h2> - <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value money">LUUN <span x-text="amountLabel(status.wallet_balance)"></span></span></div> + <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value money">IUNA <span x-text="amountLabel(status.wallet_balance)"></span></span></div> </div> <button type="button" @click="closeWalletUtxosModal">Close</button> </div> <div class="utxo-list"> <template x-for="utxo in walletUtxos" :key="`${utxo.outpoint.txid}:${utxo.outpoint.index}`"> <div class="wallet-utxo-row"> - <div class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">LUUN <span x-text="amountLabel(utxo.amount)"></span></span></div> + <div class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">IUNA <span x-text="amountLabel(utxo.amount)"></span></span></div> <div class="tx-field"><span class="tx-label">Outpoint</span><code class="tx-value hash" x-text="txInputOutpoint({ outpoint: utxo.outpoint })"></code></div> <div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="utxo.address"></code></div> </div> @@ -2133,8 +2133,8 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="tx-modal-summary"> <div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div> - <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="amountLabel(selectedTransaction?.tx?.fee ?? 0)"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">IUNA <span x-text="amountLabel(selectedTransaction?.tx?.fee ?? 0)"></span></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="txFrom(selectedTransaction?.tx || {})"></code></div> <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="txTo(selectedTransaction?.tx || {})"></code></div> <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Difficulty</span><span class="tx-value number" x-text="txDifficultyBits(selectedTransaction?.tx) ?? '-'"></span></div> @@ -2161,7 +2161,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="(output, index) in txVisualOutputs(selectedTransaction?.tx || {})" :key="txOutputKey(output, index)"> <div class="utxo-node" :class="{ burned: output.kind === 'burned', fee: output.kind === 'fee' }"> <div class="utxo-node-label"><span x-text="output.label"></span><span x-text="output.kind"></span></div> - <div class="utxo-node-amount">LUUN <span x-text="amountLabel(output.amount)"></span></div> + <div class="utxo-node-amount">IUNA <span x-text="amountLabel(output.amount)"></span></div> <template x-if="output.address"> <div class="tx-field"><span class="tx-label">To</span><code class="tx-value hash" x-text="output.address"></code></div> </template> @@ -2178,7 +2178,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 Luun</div> + <div class="setup-welcome">Welcome to iuna</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> @@ -2298,7 +2298,7 @@ mod tests { use crate::{ adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store}, app::{NodeCore, PeerBook, StratumStatus}, - domain::{Block, Ledger, MICRO_LUUN, MINE_REWARD, OutPoint, Transaction, Wallet}, + domain::{Block, Ledger, MICRO_IUNA, MINE_REWARD, OutPoint, Transaction, Wallet}, }; use super::{ @@ -2397,7 +2397,7 @@ mod tests { app.clone(), Method::GET, "/api/protected", - Some("luun_session=bogus"), + Some("iuna_session=bogus"), "", ) .await; @@ -2490,7 +2490,7 @@ mod tests { fn mine_transaction_views_include_miner_chosen_fee() { let alice = Wallet::from_seed("wallet-mine-fee-alice"); let ledger = Ledger::new(BTreeMap::new(), 1); - let mine_fee = MICRO_LUUN / 5; + let mine_fee = MICRO_IUNA / 5; let mine = ledger .build_mine_with_fee(alice.address(), mine_fee) .unwrap(); @@ -2629,16 +2629,16 @@ mod tests { &ui_config, &config_path, true, - 50 * MICRO_LUUN, - 3 * MICRO_LUUN, + 50 * MICRO_IUNA, + 3 * MICRO_IUNA, ) .await .unwrap(); let config = config_store::load_or_create(&config_path).unwrap(); assert!(config.mining_enabled); - assert_eq!(config.burn_per_block, 50 * MICRO_LUUN); - assert_eq!(config.burn_fee, 3 * MICRO_LUUN); + assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); + assert_eq!(config.burn_fee, 3 * MICRO_IUNA); } #[tokio::test] @@ -2652,13 +2652,13 @@ mod tests { let initial_config = ui_config.lock().await.clone(); config_store::save(&config_path, &initial_config).expect("initial config should save"); - persist_pow_mining_config(&ui_config, &config_path, true, 2 * MICRO_LUUN) + persist_pow_mining_config(&ui_config, &config_path, true, 2 * MICRO_IUNA) .await .unwrap(); let config = config_store::load_or_create(&config_path).unwrap(); assert!(config.pow_mining_enabled); - assert_eq!(config.pow_mine_fee, 2 * MICRO_LUUN); + assert_eq!(config.pow_mine_fee, 2 * MICRO_IUNA); } #[test] diff --git a/src/adapters/stratum.rs b/src/adapters/stratum.rs @@ -117,7 +117,7 @@ impl StratumSession { self.send_response( id, json!([ - [["mining.set_difficulty", "luun"], ["mining.notify", "luun"]], + [["mining.set_difficulty", "iuna"], ["mining.notify", "iuna"]], STRATUM_EXTRANONCE1_HEX, STRATUM_EXTRANONCE2_SIZE ]), @@ -332,7 +332,7 @@ mod tests { use super::StratumServer; #[tokio::test] - async fn stratum_session_accepts_valid_luun_share() { + async fn stratum_session_accepts_valid_iuna_share() { let wallet = Wallet::from_seed("stratum-session-wallet"); let ledger = Ledger::new(BTreeMap::new(), 1); let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet.clone(), ledger, 0))); diff --git a/src/app.rs b/src/app.rs @@ -20,7 +20,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 = "luun-devnet-v2"; +pub const NETWORK_ID: &str = "iuna-devnet-v2"; pub const BLOCK_REQUEST_LIMIT: usize = 128; const IMPORT_REBROADCAST_LIMIT: usize = 128; @@ -1237,7 +1237,7 @@ fn converge_fee_by_byte( mod tests { use std::collections::BTreeMap; - use crate::domain::{MICRO_LUUN, Transaction, Wallet}; + use crate::domain::{MICRO_IUNA, Transaction, Wallet}; use super::{NodeConfig, NodeCore}; @@ -1360,7 +1360,7 @@ mod tests { burn_fee: 0, }); - node.set_pow_mining_settings(true, MICRO_LUUN).unwrap(); + node.set_pow_mining_settings(true, MICRO_IUNA).unwrap(); let plan = node.prepare_automatic_mining(1); assert!(plan.pow_mined.is_none()); @@ -1378,17 +1378,17 @@ mod tests { let alice = Wallet::from_seed("fee-rate-alice"); let bob = Wallet::from_seed("fee-rate-bob"); let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 10 * MICRO_LUUN); + genesis.insert(alice.address().to_string(), 10 * MICRO_IUNA); let ledger = crate::domain::Ledger::new(genesis, 1); let mut node = NodeCore::from_ledger(alice, ledger, 0); let (transfer, _) = node - .transfer_with_fee_rate(bob.address(), MICRO_LUUN, 2, &[]) + .transfer_with_fee_rate(bob.address(), MICRO_IUNA, 2, &[]) .unwrap(); let minimum_transfer_fee = transfer.serialized_size_bytes().unwrap() as u64 * 2; assert!(transfer.fee() >= minimum_transfer_fee); - let (burn, _) = node.burn_with_fee_rate(MICRO_LUUN, 3).unwrap(); + let (burn, _) = node.burn_with_fee_rate(MICRO_IUNA, 3).unwrap(); let minimum_burn_fee = burn.serialized_size_bytes().unwrap() as u64 * 3; assert!(burn.fee() >= minimum_burn_fee); } diff --git a/src/domain.rs b/src/domain.rs @@ -6,11 +6,11 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; pub type Amount = u64; -pub const MICRO_LUUN: Amount = 1_000_000; -pub const BLOCK_REWARD: Amount = 100 * MICRO_LUUN; -pub const MINE_REWARD: Amount = MICRO_LUUN; +pub const MICRO_IUNA: Amount = 1_000_000; +pub const BLOCK_REWARD: Amount = 100 * MICRO_IUNA; +pub const MINE_REWARD: Amount = MICRO_IUNA; pub const DEFAULT_MINE_FEE: Amount = MINE_REWARD / 100; -pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_LUUN; +pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_IUNA; pub const DEFAULT_FEE_PER_BYTE: Amount = 1; pub const MAX_BLOCK_BYTES: usize = 100_000; pub const VDF_TARGET_BLOCK_MS: u64 = 60_000; @@ -31,7 +31,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"; +const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct Wallet { @@ -162,7 +162,7 @@ impl Transaction { amount, fee: 0, }; - let signature = hex_hash(format!("luun-genesis-burn:{}", unsigned.canonical())); + let signature = hex_hash(format!("iuna-genesis-burn:{}", unsigned.canonical())); Self::Burn { inputs: vec![input], change, @@ -310,7 +310,7 @@ impl Transaction { } return Ok(()); } - if self.signature().starts_with("luun-genesis-burn:") || self.inputs_are_genesis_signed() { + if self.signature().starts_with("iuna-genesis-burn:") || self.inputs_are_genesis_signed() { return Ok(()); } if !self @@ -491,12 +491,12 @@ fn mine_payload( ) -> String { let payload = if salt == 0 { format!( - "luun-mine:{}:{}:{}:{}", + "iuna-mine:{}:{}:{}:{}", output.address, output.amount, anchor, nonce ) } else { format!( - "luun-mine:{}:{}:{}:{}:{}", + "iuna-mine:{}:{}:{}:{}:{}", output.address, output.amount, anchor, salt, nonce ) } + &format!(":{difficulty_bits}"); @@ -579,12 +579,12 @@ fn stratum_coinbase_prefix( ) -> Vec<u8> { if salt == 0 { format!( - "luun-stratum-mine:{}:{}:{}:{}:{}:", + "iuna-stratum-mine:{}:{}:{}:{}:{}:", output.address, output.amount, fee, anchor, difficulty_bits ) } else { format!( - "luun-stratum-mine:{}:{}:{}:{}:{}:{}:", + "iuna-stratum-mine:{}:{}:{}:{}:{}:{}:", output.address, output.amount, fee, anchor, salt, difficulty_bits ) } @@ -856,7 +856,7 @@ pub struct LeaderProof { impl LeaderProof { fn rank(&self) -> String { hex_hash(format!( - "luun-leader-rank:{}:{}", + "iuna-leader-rank:{}:{}", self.ticket_id, self.signature )) } @@ -877,7 +877,7 @@ impl LeaderProofPayload { fn canonical(&self) -> String { if self.finalizer_rank == 0 { format!( - "luun-leader-proof:{}:{}:{}:{}:{}:{}", + "iuna-leader-proof:{}:{}:{}:{}:{}:{}", self.height, self.prev_hash, self.vdf_output, @@ -887,7 +887,7 @@ impl LeaderProofPayload { ) } else { format!( - "luun-leader-proof-v2:{}:{}:{}:{}:{}:{}:{}", + "iuna-leader-proof-v2:{}:{}:{}:{}:{}:{}:{}", self.height, self.prev_hash, self.finalizer_rank, @@ -1012,7 +1012,7 @@ pub struct LaunchProfile { impl Default for LaunchProfile { fn default() -> Self { Self { - profile_id: "luun-devnet-v5".to_string(), + profile_id: "iuna-devnet-v5".to_string(), ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW, mine_difficulty_bits: MINE_DIFFICULTY_BITS, @@ -1038,7 +1038,7 @@ fn default_mine_difficulty_bits() -> u32 { impl LaunchProfile { pub fn hash(&self) -> String { hex_hash(format!( - "luun-launch-profile:{}:{}:{}:{}:{}:{}:{}", + "iuna-launch-profile:{}:{}:{}:{}:{}:{}:{}", self.profile_id, self.ticket_maturity_delay_heights, self.ticket_expiry_window_heights, @@ -2233,12 +2233,12 @@ fn select_weighted_ticket_index( fn weighted_ticket_draw(parent: &Block, target_height: u64, rank: u32, total_weight: u128) -> u128 { let seed = if rank == 0 { format!( - "luun-ticket-draw:{}:{}:{}", + "iuna-ticket-draw:{}:{}:{}", target_height, parent.hash, parent.vdf_output ) } else { format!( - "luun-ticket-draw-rank:{}:{}:{}:{}", + "iuna-ticket-draw-rank:{}:{}:{}:{}", target_height, rank, parent.hash, parent.vdf_output ) }; @@ -2340,7 +2340,7 @@ fn genesis_tickets( owner.clone(), 1, hex_hash(format!( - "luun-genesis-ticket:{owner}:{amount}:{}", + "iuna-genesis-ticket:{owner}:{amount}:{}", genesis.hash )), )], @@ -2359,7 +2359,7 @@ fn genesis_bootstrap_tickets( for (owner, amount, source_id) in &source_tickets { tickets.push(BurnTicket { id: hex_hash(format!( - "luun-genesis-bootstrap-ticket:{}:{source_id}:{height}", + "iuna-genesis-bootstrap-ticket:{}:{source_id}:{height}", genesis.hash )), owner: owner.clone(), @@ -2554,7 +2554,7 @@ fn verify_leader_signature(proof: &LeaderProof, payload: &LeaderProofPayload) -> } fn vdf_seed_for_child(prev_hash: &str, height: u64) -> String { - hex_hash(format!("luun-vdf-child:{prev_hash}:{height}")) + hex_hash(format!("iuna-vdf-child:{prev_hash}:{height}")) } fn apply_transaction( @@ -2707,7 +2707,7 @@ fn build_genesis_block( .map(Transaction::canonical) .collect::<Vec<_>>() .join("|"); - let vdf_output = hex_hash(format!("luun-genesis-vdf:{genesis_allocations:?}:{txs}")); + let vdf_output = hex_hash(format!("iuna-genesis-vdf:{genesis_allocations:?}:{txs}")); let mut genesis = Block { height: 0, prev_hash: "0".repeat(64), @@ -2771,7 +2771,7 @@ fn balances_from_utxos(utxos: &BTreeMap<OutPoint, TxOutput>) -> BTreeMap<String, fn genesis_allocation_outpoint(address: &str) -> OutPoint { OutPoint { - txid: hex_hash(format!("luun-genesis-allocation:{address}")), + txid: hex_hash(format!("iuna-genesis-allocation:{address}")), index: 0, } } @@ -2866,14 +2866,14 @@ pub fn verify_vdf(seed: &str, rounds: u32, solution: &str) -> bool { } fn vdf_seed_element(seed: &str) -> u128 { - let digest = Sha256::digest(format!("luun-vdf-seed:{seed}").as_bytes()); + let digest = Sha256::digest(format!("iuna-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!("luun-vdf-challenge:{seed}:{rounds}:{output:x}")); + let digest = Sha256::digest(format!("iuna-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); @@ -3056,7 +3056,7 @@ mod tests { } fn mine_burn_block_with_mines(ledger: &mut Ledger, wallet: &Wallet, mine_actions: usize) { - let burn = ledger.build_burn(wallet, MICRO_LUUN, 0).unwrap(); + let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap(); ledger.submit_transaction(burn).unwrap(); for _ in 0..mine_actions { let mine = ledger.build_mine(wallet.address()).unwrap(); @@ -3376,7 +3376,7 @@ mod tests { #[test] fn mine_fee_cannot_exceed_reward() { let alice = Wallet::from_seed("mine-fee-too-high-alice"); - let ledger = ledger_with_allocation(&alice, MICRO_LUUN); + let ledger = ledger_with_allocation(&alice, MICRO_IUNA); let error = ledger .build_mine_with_fee(alice.address(), MINE_REWARD + 1) @@ -3388,7 +3388,7 @@ mod tests { #[test] fn mine_output_plus_fee_must_equal_reward_even_with_valid_pow() { let alice = Wallet::from_seed("mine-invalid-split-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_LUUN); + let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); let forged = mine_with_output_and_fee(&ledger, alice.address(), MINE_REWARD, 1); let error = ledger.submit_transaction(forged).unwrap_err(); @@ -3399,7 +3399,7 @@ mod tests { #[test] fn mine_fee_can_take_entire_reward_for_finalizer() { let alice = Wallet::from_seed("mine-full-fee-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_LUUN); + let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); let mine = ledger .build_mine_with_fee(alice.address(), MINE_REWARD) @@ -3413,16 +3413,16 @@ mod tests { #[test] fn block_selection_prefers_higher_fee_mine_action_when_space_is_limited() { let alice = Wallet::from_seed("mine-fee-priority-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_LUUN); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); ledger.launch_profile.max_block_transactions = 2; let low_fee_mine = ledger - .build_mine_with_fee(alice.address(), MICRO_LUUN / 100) + .build_mine_with_fee(alice.address(), MICRO_IUNA / 100) .unwrap(); let high_fee_mine = ledger - .build_mine_with_fee(alice.address(), MICRO_LUUN / 2) + .build_mine_with_fee(alice.address(), MICRO_IUNA / 2) .unwrap(); - let burn = ledger.build_burn(&alice, MICRO_LUUN, 0).unwrap(); + let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); ledger.submit_transaction(low_fee_mine.clone()).unwrap(); ledger.submit_transaction(high_fee_mine.clone()).unwrap(); ledger.submit_transaction(burn).unwrap(); @@ -3449,7 +3449,7 @@ mod tests { #[test] fn mine_difficulty_increases_when_issuance_exceeds_target_window() { let alice = Wallet::from_seed("mine-difficulty-up-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { mine_burn_block_with_mines(&mut ledger, &alice, 2); @@ -3472,7 +3472,7 @@ mod tests { #[test] fn mine_difficulty_decreases_when_issuance_is_below_target_window() { let alice = Wallet::from_seed("mine-difficulty-down-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { mine_burn_block_with_mines(&mut ledger, &alice, 0); @@ -3487,7 +3487,7 @@ mod tests { #[test] fn mine_actions_expire_when_anchor_is_too_old() { let alice = Wallet::from_seed("mine-anchor-expiry-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); let stale_mine = ledger.build_mine(alice.address()).unwrap(); for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS { @@ -3501,7 +3501,7 @@ mod tests { #[test] fn stratum_mine_header_proof_is_validated() { let alice = Wallet::from_seed("stratum-proof-alice"); - let ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN); + let ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); let anchor = ledger.tip().hash.clone(); let difficulty_bits = ledger.current_mine_difficulty_bits(); let template = ledger @@ -3539,7 +3539,7 @@ mod tests { #[test] fn stratum_mine_salt_allows_multiple_actions_for_same_anchor() { let alice = Wallet::from_seed("stratum-salt-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); let anchor = ledger.tip().hash.clone(); let difficulty_bits = ledger.current_mine_difficulty_bits(); diff --git a/src/main.rs b/src/main.rs @@ -8,14 +8,14 @@ use std::{ }; use anyhow::{Context, Result, bail}; -use luun::{ +use iuna::{ adapters::{chain_store::SqliteChainStore, config_store, http, p2p, stratum, wallet_store}, app::{NodeCore, PeerBook, SharedNode, SharedPeerBook, StratumStatus, now_ms}, - domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_LUUN, run_vdf}, + domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, run_vdf}, }; use tokio::sync::Mutex; -const GENESIS_BOOTSTRAP_BURN_AMOUNT: Amount = MICRO_LUUN; +const GENESIS_BOOTSTRAP_BURN_AMOUNT: Amount = MICRO_IUNA; const GENESIS_INITIAL_BURN_PER_BLOCK: Amount = GENESIS_BOOTSTRAP_BURN_AMOUNT; const GENESIS_INITIAL_BURN_FEE: Amount = 0; const VDF_MEASUREMENT_INITIAL_ROUNDS: u32 = 1_000_000; @@ -82,7 +82,7 @@ async fn main() -> Result<()> { persist_chain_snapshot(&chain_store, initial_snapshot).await?; } - println!("luun wallet: {}", node.lock().await.wallet_address()); + println!("iuna wallet: {}", node.lock().await.wallet_address()); if node.lock().await.wallet_is_locked() { println!("wallet locked: unlock it in the management UI"); } @@ -92,9 +92,9 @@ async fn main() -> Result<()> { println!("management UI: http://{}", opts.http_addr); println!("p2p listener: {}", opts.p2p_addr); println!( - "automatic finalization: VDF-driven, burning {} LUUN per block with {} LUUN per byte fee rate", - format_luun(initial_burn_per_block), - format_luun(initial_burn_fee) + "automatic finalization: VDF-driven, burning {} IUNA per block with {} IUNA per byte fee rate", + format_iuna(initial_burn_per_block), + format_iuna(initial_burn_fee) ); let gossip = @@ -151,7 +151,7 @@ async fn main() -> Result<()> { } enum StartupWallet { - Unlocked(luun::domain::Wallet), + Unlocked(iuna::domain::Wallet), Locked { address: String }, } @@ -182,9 +182,9 @@ fn load_startup_wallet(wallet_path: &Path) -> Result<StartupWallet> { } } -fn format_luun(amount: Amount) -> String { - let whole = amount / MICRO_LUUN; - let fractional = amount % MICRO_LUUN; +fn format_iuna(amount: Amount) -> String { + let whole = amount / MICRO_IUNA; + let fractional = amount % MICRO_IUNA; if fractional == 0 { whole.to_string() } else { @@ -264,7 +264,7 @@ impl CliOptions { peers: Vec::new(), join_peers: Vec::new(), chain_mode: ChainMode::Setup, - data_dir: PathBuf::from(".luun"), + data_dir: PathBuf::from(".iuna"), }; let raw_args = args.into_iter().collect::<Vec<_>>(); @@ -285,7 +285,7 @@ impl CliOptions { } "--wallet-seed" => { bail!( - "--wallet-seed was removed; wallets are stored in --wallet <path> or .luun/wallet.json" + "--wallet-seed was removed; wallets are stored in --wallet <path> or .iuna/wallet.json" ) } "--http" => { @@ -389,11 +389,11 @@ fn print_help() { } fn help_text() -> &'static str { - "luun\n\n\ + "iuna\n\n\ Usage:\n\ - luun [options]\n\ - luun --genesis [options]\n\ - luun --join <addr:port> [options]\n\n\ + iuna [options]\n\ + iuna --genesis [options]\n\ + iuna --join <addr:port> [options]\n\n\ Options:\n\ --genesis Create a new chain with a fresh setup wallet\n\ --wallet <path> Wallet file (default <data-dir>/wallet.json)\n\ @@ -404,7 +404,7 @@ fn help_text() -> &'static str { --join <addr:port> Fetch chain snapshot from this peer before finalization\n\ --data-dir <path> Local wallet directory\n\n\ Environment:\n\ - LUUN_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" + IUNA_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" } fn snapshot_height(snapshot: &ChainSnapshot) -> u64 { @@ -434,7 +434,7 @@ fn start_genesis_ledger(wallet_address: &str) -> Result<Ledger> { } fn measure_initial_vdf_rounds() -> u32 { - let seed = "luun-vdf-calibration"; + let seed = "iuna-vdf-calibration"; let (measured_rounds, elapsed) = measure_vdf_rounds( seed, VDF_MEASUREMENT_INITIAL_ROUNDS, @@ -630,10 +630,10 @@ async fn persist_chain_snapshot(store: &SqliteChainStore, snapshot: ChainSnapsho mod tests { use std::{collections::BTreeMap, sync::Arc, time::Duration}; - use luun::{ + use iuna::{ adapters::{chain_store::SqliteChainStore, config_store::UiConfig, wallet_store}, app::{DEFAULT_BURN_PER_BLOCK, NodeCore}, - domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_LUUN, Wallet}, + domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_IUNA, Wallet}, }; use rusqlite::Connection; use tempfile::tempdir; @@ -652,12 +652,12 @@ mod tests { #[test] fn help_mentions_dev_seed_verify_bypass_env() { - assert!(help_text().contains("LUUN_DEV_SKIP_SEED_VERIFY=1")); + assert!(help_text().contains("IUNA_DEV_SKIP_SEED_VERIFY=1")); assert!(help_text().contains("skip seed verification")); assert!(help_text().contains("--stratum <addr:port>")); } - fn ledger_with_one_spendable_luun(wallet: &Wallet) -> Ledger { + fn ledger_with_one_spendable_iuna(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) @@ -665,7 +665,7 @@ mod tests { } fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger { - let mut ledger = ledger_with_one_spendable_luun(wallet); + let mut ledger = ledger_with_one_spendable_iuna(wallet); let burn = ledger.build_burn(wallet, 1, 0).unwrap(); ledger.submit_transaction(burn).unwrap(); let block = ledger.mine_next_block(wallet, 1_000).unwrap(); @@ -762,10 +762,10 @@ mod tests { fn genesis_default_auto_mining_keeps_burning_after_first_block() { let wallet = Wallet::from_seed("genesis-auto-burn-wallet"); let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), MICRO_LUUN); + genesis.insert(wallet.address().to_string(), MICRO_IUNA); let ledger = Ledger::new_with_genesis_burns( genesis, - vec![GenesisBurn::new(wallet.address(), MICRO_LUUN)], + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], 1, ) .unwrap(); @@ -781,12 +781,12 @@ mod tests { assert_eq!( first.burned.as_ref().map(|tx| tx.amount()), - Some(MICRO_LUUN) + Some(MICRO_IUNA) ); assert!(first.block.is_some(), "{first:?}"); assert_eq!( second.burned.as_ref().map(|tx| tx.amount()), - Some(MICRO_LUUN) + Some(MICRO_IUNA) ); assert!(second.block.is_some(), "{second:?}"); assert!( @@ -795,7 +795,7 @@ mod tests { }), "{second:?}" ); - assert!(node.ledger().balance_of(wallet.address()) >= BLOCK_REWARD - 2 * MICRO_LUUN); + assert!(node.ledger().balance_of(wallet.address()) >= BLOCK_REWARD - 2 * MICRO_IUNA); } #[test] @@ -846,7 +846,7 @@ mod tests { } #[test] - fn http_management_port_defaults_to_luun_port() { + fn http_management_port_defaults_to_iuna_port() { let opts = parse(&[]).unwrap().unwrap(); assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661"); } @@ -936,7 +936,7 @@ mod tests { 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); + measure_vdf_rounds("iuna-test-vdf-calibration", 1, min_elapsed, max_rounds); assert!(rounds >= 1); assert!(elapsed >= min_elapsed || rounds >= max_rounds); @@ -1050,7 +1050,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_luun(&wallet); + let ledger = ledger_with_one_spendable_iuna(&wallet); let node = Arc::new(Mutex::new(NodeCore::from_ledger( wallet.clone(), ledger, diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -0,0 +1,2129 @@ +use std::collections::BTreeMap; + +use iuna::{ + adapters::chain_store::SqliteChainStore, + app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection}, + domain::{ + Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, GenesisBurn, Ledger, MAX_BLOCK_BYTES, + MICRO_IUNA, MINE_REWARD, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, + }, +}; +use tempfile::tempdir; + +fn iuna(amount: Amount) -> Amount { + amount * MICRO_IUNA +} + +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, + burn_fee: 1, + }) +} + +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 { + let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap(); + let hash = block.hash.clone(); + ledger.apply_block(block).unwrap(); + hash +} + +fn submit_burn(ledger: &mut Ledger, wallet: &Wallet, amount: Amount) { + let tx = ledger.build_burn(wallet, amount, 0).unwrap(); + ledger.submit_transaction(tx).unwrap(); +} + +fn burn_tx(ledger: &Ledger, wallet: &Wallet, amount: Amount) -> iuna::domain::Transaction { + ledger.build_burn(wallet, amount, 0).unwrap() +} + +fn transfer_tx( + ledger: &Ledger, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, +) -> iuna::domain::Transaction { + ledger.build_transfer(wallet, to, amount, 0).unwrap() +} + +fn transfer_fee_tx( + ledger: &Ledger, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, + fee: Amount, +) -> iuna::domain::Transaction { + ledger.build_transfer(wallet, to, amount, fee).unwrap() +} + +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_with_burn_fee_and_enabled( + wallet, + ledger, + true, + DEFAULT_BURN_PER_BLOCK, + DEFAULT_FEE_PER_BYTE, + ) +} + +#[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 burn_amount_weights_leader_selection() { + let mut high_weight_leaders = 0; + for sample in 0..100 { + let low = Wallet::from_seed(&format!("weighted-low-{sample}")); + let high = Wallet::from_seed(&format!("weighted-high-{sample}")); + let mut allocations = BTreeMap::new(); + allocations.insert(low.address().to_string(), 1_000); + allocations.insert(high.address().to_string(), 1_000); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![ + GenesisBurn::new(low.address(), 1), + GenesisBurn::new(high.address(), 99), + ], + 25, + ) + .unwrap(); + + if ledger.expected_leader_for_next_block().as_deref() == Some(high.address()) { + high_weight_leaders += 1; + } + } + + assert!( + high_weight_leaders >= 90, + "high burn ticket should win most weighted draws, won {high_weight_leaders}/100" + ); +} + +#[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_block_creates_ticket_after_maturity_delay() { + 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(), MICRO_IUNA); + + let mut ledger = Ledger::new(allocations, 10); + submit_burn(&mut ledger, &bob, 80); + + let launch_leader = + if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) { + &alice + } else { + &bob + }; + let first = ledger.mine_next_block(launch_leader, 1).unwrap(); + ledger.apply_block(first).unwrap(); + + for height in 2..=3 { + let leader_wallet = + if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) { + &alice + } else { + &bob + }; + submit_burn(&mut ledger, leader_wallet, 1); + let block = ledger.mine_next_block(leader_wallet, height).unwrap(); + ledger.apply_block(block).unwrap(); + } + + assert_eq!( + ledger.expected_leader_for_next_block().as_deref(), + Some(bob.address()) + ); + + assert!( + ledger.mine_next_block(&alice, 4).is_err(), + "the block 1 burn should not be eligible before its maturity delay, and only Bob should hold it at block 4" + ); + + submit_burn(&mut ledger, &bob, 1); + assert!(ledger.mine_next_block(&bob, 4).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(), iuna(1_000)); + allocations.insert(bob.address().to_string(), iuna(100)); + + let mut ledger = Ledger::new(allocations, 10); + let transfer = transfer_tx(&ledger, &alice, bob.address(), iuna(125)); + ledger.submit_transaction(transfer).unwrap(); + let burn = burn_tx(&ledger, &alice, iuna(25)); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + ledger.apply_block(block).unwrap(); + + assert_eq!(ledger.balance_of(alice.address()), iuna(850)); + assert_eq!(ledger.balance_of(bob.address()), iuna(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(), MICRO_IUNA); + let mut ledger = Ledger::new(allocations, 10); + + let mut forged = burn_tx(&ledger, &bob, 10); + if let iuna::domain::Transaction::Burn { inputs, .. } = &mut forged { + inputs[0].owner = 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); + submit_burn(&mut ledger, &alice, 1); + + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); + if let iuna::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 mine_action_introduces_one_iuna_and_block_author_gets_fees_only() { + let alice = Wallet::from_seed("alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 2 * MICRO_IUNA); + + let mut ledger = Ledger::new(allocations, 10); + let mine = ledger.build_mine(alice.address()).unwrap(); + assert_eq!(mine.amount(), MINE_REWARD); + ledger.submit_transaction(mine.clone()).unwrap(); + submit_burn(&mut ledger, &alice, MICRO_IUNA); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert_eq!(block.reward, 0); + + ledger.apply_block(block).unwrap(); + assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_IUNA); + assert!(!ledger.submit_transaction(mine).unwrap()); + assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_IUNA); +} + +#[test] +fn mine_action_fee_is_chosen_by_pow_miner_and_paid_to_block_finalizer() { + let alice = Wallet::from_seed("mine-fee-alice"); + let bob = Wallet::from_seed("mine-fee-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(bob.address().to_string(), 2 * MICRO_IUNA); + + let mut ledger = Ledger::new(allocations, 10); + let mine_fee = MICRO_IUNA / 4; + let mine = ledger + .build_mine_with_fee(alice.address(), mine_fee) + .unwrap(); + assert_eq!(mine.amount(), MINE_REWARD - mine_fee); + assert_eq!(mine.fee(), mine_fee); + ledger.submit_transaction(mine).unwrap(); + submit_burn(&mut ledger, &bob, MICRO_IUNA); + + let block = ledger.mine_next_block(&bob, 1).unwrap(); + assert_eq!(block.reward, mine_fee); + ledger.apply_block(block).unwrap(); + + assert_eq!(ledger.balance_of(alice.address()), MINE_REWARD - mine_fee); + assert_eq!(ledger.balance_of(bob.address()), MICRO_IUNA + mine_fee); +} + +#[test] +fn forged_mine_action_cannot_introduce_iuna() { + let alice = Wallet::from_seed("forged-mine-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), MICRO_IUNA); + let mut ledger = Ledger::new(allocations, 10); + + let mut forged = ledger.build_mine(alice.address()).unwrap(); + if let iuna::domain::Transaction::Mine { nonce, .. } = &mut forged { + *nonce += 1; + } + + let error = ledger.submit_transaction(forged).unwrap_err(); + assert!(format!("{error:#}").contains("mine transaction proof hash is invalid")); + assert_eq!(ledger.balance_of(alice.address()), MICRO_IUNA); +} + +#[test] +fn burn_larger_than_mine_reward_is_paid_from_existing_utxos() { + let alice = Wallet::from_seed("large-burn-existing-utxos-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), BLOCK_REWARD + iuna(50)); + + let mut ledger = Ledger::new(allocations, 10); + let burn_amount = BLOCK_REWARD + iuna(25); + let burn = ledger.build_burn(&alice, burn_amount, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert_eq!(block.transactions[0].amount(), burn_amount); + assert_eq!(block.reward, 0); + + ledger.apply_block(block).unwrap(); + assert_eq!(ledger.balance_of(alice.address()), iuna(25)); +} + +#[test] +fn burn_larger_than_existing_balance_cannot_use_next_block_reward() { + let alice = Wallet::from_seed("large-burn-cannot-use-next-reward-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), BLOCK_REWARD); + + let ledger = Ledger::new(allocations, 10); + let error = ledger + .build_burn(&alice, BLOCK_REWARD + MICRO_IUNA, 0) + .unwrap_err(); + + assert!(format!("{error:#}").contains("insufficient funds")); +} + +#[test] +fn transaction_fees_are_paid_to_the_block_finalizer() { + 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(), MICRO_IUNA); + allocations.insert(bob.address().to_string(), iuna(200)); + + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], + 10, + ) + .unwrap(); + submit_burn(&mut ledger, &alice, MICRO_IUNA); + let tx = transfer_fee_tx(&ledger, &bob, alice.address(), iuna(10), iuna(7)); + ledger.submit_transaction(tx).unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert_eq!(block.reward, iuna(7)); + ledger.apply_block(block).unwrap(); + + assert_eq!(ledger.balance_of(alice.address()), iuna(116)); + assert_eq!(ledger.balance_of(bob.address()), iuna(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 = burn_tx(&ledger, alice, 1); + let low_fee = transfer_fee_tx(&ledger, bob, alice.address(), 1, 1); + let high_fee = transfer_fee_tx(&ledger, carol, alice.address(), 1, 20); + 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(); + submit_burn(&mut ledger, &alice, 1); + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); + let oversized_transfer = transfer_fee_tx(&ledger, &bob, "x".repeat(MAX_BLOCK_BYTES), 1, 3); + block.transactions.push(oversized_transfer); + 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(); + submit_burn(&mut ledger, alice, 1); + let oversized = transfer_fee_tx(&ledger, bob, "x".repeat(MAX_BLOCK_BYTES), 1, 100_000); + let fitting = transfer_fee_tx(&ledger, carol, alice.address(), 1, 5); + 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 ledger = Ledger::new(allocations, 10); + let error = ledger + .build_transfer(&alice, bob.address(), 1, 0) + .unwrap_err(); + + assert!(format!("{error:#}").contains("balance overflow")); +} + +#[test] +fn mine_reward_that_would_overflow_recipient_balance_is_rejected() { + let alice = Wallet::from_seed("alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), Amount::MAX); + + let ledger = Ledger::new(allocations, 10); + let error = ledger.build_mine(alice.address()).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); + submit_burn(&mut ledger, &alice, 1); + 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); + submit_burn(&mut ledger, &alice, 1); + 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(), iuna(1_000)); + + let mut node = NodeCore::new(NodeConfig { + wallet: alice.clone(), + genesis_allocations: allocations, + vdf_rounds: 10, + burn_per_block: iuna(25), + burn_fee: DEFAULT_FEE_PER_BYTE, + }); + + 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()), iuna(975)); + + let second = node.automatic_mine_once(2); + assert!(second.burned.is_some()); + let burned = second.burned.as_ref().unwrap(); + assert_eq!(burned.amount(), iuna(25)); + assert_eq!( + burned.fee(), + burned.serialized_size_bytes().unwrap() as u64 * DEFAULT_FEE_PER_BYTE + ); +} + +#[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("automatic mining is off")) + ); + 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!(node.status().mining.automatic); + assert_eq!(node.status().mining.burn_per_block, 25); + let burned = node.set_burn_per_block(0).unwrap(); + assert!(burned.is_none()); + assert!(!node.status().mining.automatic); + 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, 1); + + node.set_burn_per_block(1).unwrap(); + assert_eq!(node.status().mining.burn_per_block, 1); + assert_eq!(node.status().mining.automatic_burn_fee, 1); + + node.set_automatic_burn(50, 3).unwrap(); + assert_eq!(node.status().mining.burn_per_block, 50); + assert_eq!(node.status().mining.automatic_burn_fee, 3); +} + +#[test] +fn automatic_mining_uses_configured_burn_fee() { + let alice = Wallet::from_seed("auto-fee-burn-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), MICRO_IUNA); + let mut node = node("alice", alice, allocations); + + let burned = node.set_automatic_burn(50, 3).unwrap().unwrap(); + assert_eq!(burned.amount(), 50); + assert_eq!( + burned.fee(), + burned.serialized_size_bytes().unwrap() as u64 * 3 + ); +} + +#[test] +fn automatic_mining_caps_burn_to_spendable_balance_after_fee() { + let alice = Wallet::from_seed("auto-burn-cap-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), BLOCK_REWARD); + let mut node = NodeCore::new(NodeConfig { + wallet: alice.clone(), + genesis_allocations: allocations, + vdf_rounds: 10, + burn_per_block: BLOCK_REWARD + iuna(50), + burn_fee: DEFAULT_FEE_PER_BYTE, + }); + + let outcome = node.automatic_mine_once(1); + + let burned = outcome.burned.as_ref().unwrap(); + let unspent = BLOCK_REWARD - burned.amount() - burned.fee(); + assert!(unspent <= DEFAULT_FEE_PER_BYTE); + assert_eq!( + burned.fee(), + burned.serialized_size_bytes().unwrap() as u64 * DEFAULT_FEE_PER_BYTE + ); + assert!(outcome.block.is_some()); + assert_eq!( + node.ledger().balance_of(alice.address()), + burned.fee() + unspent + ); +} + +#[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(), MICRO_IUNA); + + let mut ledger = Ledger::new(allocations.clone(), 25); + submit_burn(&mut ledger, &alice, 1); + 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(iuna::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(), MICRO_IUNA); + + let mut ledger = Ledger::new(allocations.clone(), 25); + submit_burn(&mut ledger, &alice, 1); + 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(iuna::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 waiting_wallet_gossips_pending_burn_to_selected_leader() { + let alice = Wallet::from_seed("deadlock-alice"); + let bob = Wallet::from_seed("deadlock-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), iuna(1_000)); + allocations.insert(bob.address().to_string(), iuna(1_000)); + let ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(bob.address(), 1)], 25) + .unwrap(); + + assert_eq!( + ledger.expected_leader_for_next_block().as_deref(), + Some(bob.address()) + ); + let mut alice_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 1); + let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled( + bob.clone(), + ledger, + true, + DEFAULT_BURN_PER_BLOCK, + MICRO_IUNA, + ); + + let alice_outcome = alice_node.automatic_mine_once(1); + assert!(alice_outcome.burned.is_some()); + assert!(alice_outcome.block.is_none()); + assert!( + alice_outcome + .skipped_reason + .unwrap() + .contains(bob.address()) + ); + assert!(bob_node.ledger().pending().is_empty()); + + for envelope in alice_node.mempool_gossip() { + bob_node.receive(envelope).unwrap(); + } + + assert_eq!(bob_node.ledger().pending().len(), 1); + assert_eq!(bob_node.ledger().pending()[0].sender(), alice.address()); + let bob_outcome = bob_node.automatic_mine_once(1); + assert!(bob_outcome.skipped_reason.is_none()); + assert!(bob_outcome.block.is_some()); +} + +#[test] +fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() { + let alice = Wallet::from_seed("pob-only-finalizer-alice"); + let bob = Wallet::from_seed("pow-only-miner-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 2 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], + 25, + ) + .unwrap(); + + assert_eq!( + ledger.expected_leader_for_next_block().as_deref(), + Some(alice.address()) + ); + let mut alice_node = NodeCore::from_ledger_with_burn_fee_and_enabled( + alice, + ledger.clone(), + true, + DEFAULT_BURN_PER_BLOCK, + DEFAULT_FEE_PER_BYTE, + ); + let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled( + bob.clone(), + ledger, + false, + DEFAULT_BURN_PER_BLOCK, + DEFAULT_FEE_PER_BYTE, + ); + bob_node + .set_pow_mining_settings(true, DEFAULT_FEE_PER_BYTE) + .unwrap(); + + let bob_plan = bob_node.prepare_automatic_mining(1); + let mine = bob_plan.pow_mined.expect("B should queue a mine action"); + assert_eq!( + bob_plan.skipped_reason.as_deref(), + Some("automatic mining is off") + ); + assert!(alice_node.ledger().pending().is_empty()); + + for envelope in bob_node.drain_outbox() { + alice_node.receive(envelope).unwrap(); + } + + assert!( + alice_node + .ledger() + .pending() + .iter() + .any(|pending| pending.signature() == mine.signature()), + "A did not receive B's mine action" + ); +} + +#[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(); + submit_burn(&mut ledger, &wallet, 1); + + 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 fallback_finalizer_can_build_block_with_extra_vdf_rounds() { + let alice = Wallet::from_seed("fallback-finalizer-alice"); + let bob = Wallet::from_seed("fallback-finalizer-bob"); + let wallets = [&alice, &bob]; + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let mut ledger = Ledger::new_with_genesis_burns( + genesis, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 25, + ) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let fallback = wallets + .into_iter() + .find(|wallet| wallet.address() != leader) + .unwrap(); + + submit_burn(&mut ledger, fallback, 1); + let block = ledger.mine_next_block(fallback, 1).unwrap(); + + assert_eq!(block.miner, fallback.address()); + assert_eq!(block.finalizer_rank, 1); + assert_eq!(block.vdf_rounds, 50); + ledger.apply_block(block).unwrap(); +} + +#[test] +fn fallback_finalizer_with_primary_vdf_rounds_is_rejected() { + let alice = Wallet::from_seed("fallback-rounds-alice"); + let bob = Wallet::from_seed("fallback-rounds-bob"); + let wallets = [&alice, &bob]; + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let mut ledger = Ledger::new_with_genesis_burns( + genesis, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 25, + ) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let fallback = wallets + .into_iter() + .find(|wallet| wallet.address() != leader) + .unwrap(); + + submit_burn(&mut ledger, fallback, 1); + let mut block = ledger.mine_next_block(fallback, 1).unwrap(); + block.vdf_rounds = 25; + 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!(format!("{error:#}").contains("block VDF rounds are invalid")); +} + +#[test] +fn fork_choice_prefers_primary_finalizer_over_fallback_rank() { + let alice = Wallet::from_seed("fallback-fork-alice"); + let bob = Wallet::from_seed("fallback-fork-bob"); + let wallets = [&alice, &bob]; + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let common = Ledger::new_with_genesis_burns( + genesis, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 25, + ) + .unwrap(); + let leader_address = common.expected_leader_for_next_block().unwrap(); + let leader = wallets + .iter() + .copied() + .find(|wallet| wallet.address() == leader_address) + .unwrap(); + let fallback = wallets + .into_iter() + .find(|wallet| wallet.address() != leader_address) + .unwrap(); + + let mut local = common.clone(); + submit_burn(&mut local, fallback, 1); + let fallback_block = local.mine_next_block(fallback, 1).unwrap(); + assert_eq!(fallback_block.finalizer_rank, 1); + local.apply_block(fallback_block).unwrap(); + + let mut remote = common; + submit_burn(&mut remote, leader, 1); + let leader_block = remote.mine_next_block(leader, 1).unwrap(); + assert_eq!(leader_block.finalizer_rank, 0); + let leader_hash = leader_block.hash.clone(); + remote.apply_block(leader_block).unwrap(); + + assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); + assert_eq!(local.status().tip_hash, leader_hash); +} + +#[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); + + submit_burn(&mut ledger, &wallet, 1); + 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); + + submit_burn(&mut ledger, &wallet, 1); + 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); + + submit_burn(&mut ledger, &wallet, 1); + 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 conflicting_utxo_spends_are_not_accepted_together() { + 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 first = burn_tx(&ledger, &wallet, 3); + let conflicting = burn_tx(&ledger, &wallet, 4); + ledger.submit_transaction(first.clone()).unwrap(); + assert!(!ledger.submit_transaction(conflicting).unwrap()); + assert_eq!(ledger.pending().len(), 1); + + let block = ledger + .prepare_next_block(wallet.address(), 1) + .unwrap() + .finish(&wallet, "test-vdf".to_string()); + assert_eq!(block.transactions.len(), 1); + assert!(block.transactions.contains(&first)); +} + +#[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(), iuna(100)); + let alice_ledger = Ledger::new_with_genesis_burns( + genesis, + vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], + 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_with_fee(bob.address(), iuna(30), 0) + .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| tx.to() == Some(bob.address()) && tx.amount() == iuna(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()), + iuna(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(); + + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block7 = network.node_mut("a").unwrap().mine_one_at(7).unwrap(); + mined_by.push(block7.miner.clone()); + network.deliver_until_idle().unwrap(); + + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block8 = network.node_mut("a").unwrap().mine_one_at(8).unwrap(); + mined_by.push(block8.miner.clone()); + network.deliver_until_idle().unwrap(); + + network + .node_mut("b") + .unwrap() + .transfer_with_fee(carol.address(), iuna(10), 0) + .unwrap(); + network.node_mut("b").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + assert_eq!( + network + .node("a") + .unwrap() + .ledger() + .expected_leader_for_next_block(), + Some(bob.address().to_string()) + ); + let block9 = network.node_mut("b").unwrap().mine_one_at(9).unwrap(); + mined_by.push(block9.miner.clone()); + network.deliver_until_idle().unwrap(); + + network.node_mut("c").unwrap().burn(5).unwrap(); + network.deliver_until_idle().unwrap(); + let block10 = network.node_mut("a").unwrap().mine_one_at(10).unwrap(); + mined_by.push(block10.miner.clone()); + network.deliver_until_idle().unwrap(); + + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block11 = network.node_mut("a").unwrap().mine_one_at(11).unwrap(); + mined_by.push(block11.miner.clone()); + network.deliver_until_idle().unwrap(); + + network.node_mut("b").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + let block12 = network.node_mut("b").unwrap().mine_one_at(12).unwrap(); + mined_by.push(block12.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 block13 = network.node_mut("c").unwrap().mine_one_at(13).unwrap(); + mined_by.push(block13.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, 13); + 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(); + + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + network.node_mut("a").unwrap().mine_one_at(4).unwrap(); + network.deliver_until_idle().unwrap(); + + network.node_mut("a").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + network.node_mut("a").unwrap().mine_one_at(5).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(6).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, + burn_fee: 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], + iuna::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], + iuna::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(iuna::app::GossipEnvelope::Transaction(tx.clone())) + .unwrap(); + let forwarded = hub.drain_outbox(); + assert_eq!(forwarded.len(), 1); + assert!(matches!( + forwarded[0], + iuna::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(iuna::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(iuna::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 { + iuna::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 { + iuna::app::GossipEnvelope::TransactionRequest { signatures } => { + bob_node + .receive(iuna::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(iuna::app::GossipEnvelope::Block(block.clone())) + .unwrap(); + let forwarded = hub.drain_outbox(); + assert_eq!(forwarded.len(), 1); + assert!(matches!(forwarded[0], iuna::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(iuna::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] { + iuna::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); + submit_burn(&mut ledger, &alice, 10); + 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 + .set_automatic_burn_settings(true, DEFAULT_BURN_PER_BLOCK, DEFAULT_FEE_PER_BYTE) + .unwrap(); + 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 { + iuna::domain::Transaction::Burn { signature, .. } + | iuna::domain::Transaction::Transfer { signature, .. } + | iuna::domain::Transaction::Mine { 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 = transfer_tx(&local, &bob, carol.address(), 7); + 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 = burn_tx(&local, &alice, 1); + 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 = transfer_tx(&local, &bob, alice.address(), 5); + local.submit_transaction(local_transfer.clone()).unwrap(); + + submit_burn(&mut remote, &alice, 1); + let remote_block_1 = remote.mine_next_block(&alice, 1).unwrap(); + remote.apply_block(remote_block_1).unwrap(); + submit_burn(&mut remote, &alice, 1); + 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(iuna::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 @@ -1,2129 +0,0 @@ -use std::collections::BTreeMap; - -use luun::{ - adapters::chain_store::SqliteChainStore, - app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection}, - domain::{ - Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, GenesisBurn, Ledger, MAX_BLOCK_BYTES, - MICRO_LUUN, MINE_REWARD, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, - }, -}; -use tempfile::tempdir; - -fn luun(amount: Amount) -> Amount { - amount * MICRO_LUUN -} - -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, - burn_fee: 1, - }) -} - -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 { - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap(); - let hash = block.hash.clone(); - ledger.apply_block(block).unwrap(); - hash -} - -fn submit_burn(ledger: &mut Ledger, wallet: &Wallet, amount: Amount) { - let tx = ledger.build_burn(wallet, amount, 0).unwrap(); - ledger.submit_transaction(tx).unwrap(); -} - -fn burn_tx(ledger: &Ledger, wallet: &Wallet, amount: Amount) -> luun::domain::Transaction { - ledger.build_burn(wallet, amount, 0).unwrap() -} - -fn transfer_tx( - ledger: &Ledger, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, -) -> luun::domain::Transaction { - ledger.build_transfer(wallet, to, amount, 0).unwrap() -} - -fn transfer_fee_tx( - ledger: &Ledger, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, - fee: Amount, -) -> luun::domain::Transaction { - ledger.build_transfer(wallet, to, amount, fee).unwrap() -} - -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_with_burn_fee_and_enabled( - wallet, - ledger, - true, - DEFAULT_BURN_PER_BLOCK, - DEFAULT_FEE_PER_BYTE, - ) -} - -#[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 burn_amount_weights_leader_selection() { - let mut high_weight_leaders = 0; - for sample in 0..100 { - let low = Wallet::from_seed(&format!("weighted-low-{sample}")); - let high = Wallet::from_seed(&format!("weighted-high-{sample}")); - let mut allocations = BTreeMap::new(); - allocations.insert(low.address().to_string(), 1_000); - allocations.insert(high.address().to_string(), 1_000); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(low.address(), 1), - GenesisBurn::new(high.address(), 99), - ], - 25, - ) - .unwrap(); - - if ledger.expected_leader_for_next_block().as_deref() == Some(high.address()) { - high_weight_leaders += 1; - } - } - - assert!( - high_weight_leaders >= 90, - "high burn ticket should win most weighted draws, won {high_weight_leaders}/100" - ); -} - -#[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_block_creates_ticket_after_maturity_delay() { - 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(), MICRO_LUUN); - - let mut ledger = Ledger::new(allocations, 10); - submit_burn(&mut ledger, &bob, 80); - - let launch_leader = - if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) { - &alice - } else { - &bob - }; - let first = ledger.mine_next_block(launch_leader, 1).unwrap(); - ledger.apply_block(first).unwrap(); - - for height in 2..=3 { - let leader_wallet = - if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) { - &alice - } else { - &bob - }; - submit_burn(&mut ledger, leader_wallet, 1); - let block = ledger.mine_next_block(leader_wallet, height).unwrap(); - ledger.apply_block(block).unwrap(); - } - - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(bob.address()) - ); - - assert!( - ledger.mine_next_block(&alice, 4).is_err(), - "the block 1 burn should not be eligible before its maturity delay, and only Bob should hold it at block 4" - ); - - submit_burn(&mut ledger, &bob, 1); - assert!(ledger.mine_next_block(&bob, 4).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(), luun(1_000)); - allocations.insert(bob.address().to_string(), luun(100)); - - let mut ledger = Ledger::new(allocations, 10); - let transfer = transfer_tx(&ledger, &alice, bob.address(), luun(125)); - ledger.submit_transaction(transfer).unwrap(); - let burn = burn_tx(&ledger, &alice, luun(25)); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.balance_of(alice.address()), luun(850)); - assert_eq!(ledger.balance_of(bob.address()), luun(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(), MICRO_LUUN); - let mut ledger = Ledger::new(allocations, 10); - - let mut forged = burn_tx(&ledger, &bob, 10); - if let luun::domain::Transaction::Burn { inputs, .. } = &mut forged { - inputs[0].owner = 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); - submit_burn(&mut ledger, &alice, 1); - - 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 mine_action_introduces_one_luun_and_block_author_gets_fees_only() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 2 * MICRO_LUUN); - - let mut ledger = Ledger::new(allocations, 10); - let mine = ledger.build_mine(alice.address()).unwrap(); - assert_eq!(mine.amount(), MINE_REWARD); - ledger.submit_transaction(mine.clone()).unwrap(); - submit_burn(&mut ledger, &alice, MICRO_LUUN); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, 0); - - ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_LUUN); - assert!(!ledger.submit_transaction(mine).unwrap()); - assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_LUUN); -} - -#[test] -fn mine_action_fee_is_chosen_by_pow_miner_and_paid_to_block_finalizer() { - let alice = Wallet::from_seed("mine-fee-alice"); - let bob = Wallet::from_seed("mine-fee-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(bob.address().to_string(), 2 * MICRO_LUUN); - - let mut ledger = Ledger::new(allocations, 10); - let mine_fee = MICRO_LUUN / 4; - let mine = ledger - .build_mine_with_fee(alice.address(), mine_fee) - .unwrap(); - assert_eq!(mine.amount(), MINE_REWARD - mine_fee); - assert_eq!(mine.fee(), mine_fee); - ledger.submit_transaction(mine).unwrap(); - submit_burn(&mut ledger, &bob, MICRO_LUUN); - - let block = ledger.mine_next_block(&bob, 1).unwrap(); - assert_eq!(block.reward, mine_fee); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.balance_of(alice.address()), MINE_REWARD - mine_fee); - assert_eq!(ledger.balance_of(bob.address()), MICRO_LUUN + mine_fee); -} - -#[test] -fn forged_mine_action_cannot_introduce_luun() { - let alice = Wallet::from_seed("forged-mine-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_LUUN); - let mut ledger = Ledger::new(allocations, 10); - - let mut forged = ledger.build_mine(alice.address()).unwrap(); - if let luun::domain::Transaction::Mine { nonce, .. } = &mut forged { - *nonce += 1; - } - - let error = ledger.submit_transaction(forged).unwrap_err(); - assert!(format!("{error:#}").contains("mine transaction proof hash is invalid")); - assert_eq!(ledger.balance_of(alice.address()), MICRO_LUUN); -} - -#[test] -fn burn_larger_than_mine_reward_is_paid_from_existing_utxos() { - let alice = Wallet::from_seed("large-burn-existing-utxos-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), BLOCK_REWARD + luun(50)); - - let mut ledger = Ledger::new(allocations, 10); - let burn_amount = BLOCK_REWARD + luun(25); - let burn = ledger.build_burn(&alice, burn_amount, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.transactions[0].amount(), burn_amount); - assert_eq!(block.reward, 0); - - ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), luun(25)); -} - -#[test] -fn burn_larger_than_existing_balance_cannot_use_next_block_reward() { - let alice = Wallet::from_seed("large-burn-cannot-use-next-reward-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), BLOCK_REWARD); - - let ledger = Ledger::new(allocations, 10); - let error = ledger - .build_burn(&alice, BLOCK_REWARD + MICRO_LUUN, 0) - .unwrap_err(); - - assert!(format!("{error:#}").contains("insufficient funds")); -} - -#[test] -fn transaction_fees_are_paid_to_the_block_finalizer() { - 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(), MICRO_LUUN); - allocations.insert(bob.address().to_string(), luun(200)); - - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), MICRO_LUUN)], - 10, - ) - .unwrap(); - submit_burn(&mut ledger, &alice, MICRO_LUUN); - let tx = transfer_fee_tx(&ledger, &bob, alice.address(), luun(10), luun(7)); - ledger.submit_transaction(tx).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, luun(7)); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.balance_of(alice.address()), luun(116)); - assert_eq!(ledger.balance_of(bob.address()), luun(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 = burn_tx(&ledger, alice, 1); - let low_fee = transfer_fee_tx(&ledger, bob, alice.address(), 1, 1); - let high_fee = transfer_fee_tx(&ledger, carol, alice.address(), 1, 20); - 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(); - submit_burn(&mut ledger, &alice, 1); - let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - let oversized_transfer = transfer_fee_tx(&ledger, &bob, "x".repeat(MAX_BLOCK_BYTES), 1, 3); - block.transactions.push(oversized_transfer); - 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(); - submit_burn(&mut ledger, alice, 1); - let oversized = transfer_fee_tx(&ledger, bob, "x".repeat(MAX_BLOCK_BYTES), 1, 100_000); - let fitting = transfer_fee_tx(&ledger, carol, alice.address(), 1, 5); - 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 ledger = Ledger::new(allocations, 10); - let error = ledger - .build_transfer(&alice, bob.address(), 1, 0) - .unwrap_err(); - - assert!(format!("{error:#}").contains("balance overflow")); -} - -#[test] -fn mine_reward_that_would_overflow_recipient_balance_is_rejected() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), Amount::MAX); - - let ledger = Ledger::new(allocations, 10); - let error = ledger.build_mine(alice.address()).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); - submit_burn(&mut ledger, &alice, 1); - 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); - submit_burn(&mut ledger, &alice, 1); - 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(), luun(1_000)); - - let mut node = NodeCore::new(NodeConfig { - wallet: alice.clone(), - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: luun(25), - burn_fee: DEFAULT_FEE_PER_BYTE, - }); - - 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()), luun(975)); - - let second = node.automatic_mine_once(2); - assert!(second.burned.is_some()); - let burned = second.burned.as_ref().unwrap(); - assert_eq!(burned.amount(), luun(25)); - assert_eq!( - burned.fee(), - burned.serialized_size_bytes().unwrap() as u64 * DEFAULT_FEE_PER_BYTE - ); -} - -#[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("automatic mining is off")) - ); - 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!(node.status().mining.automatic); - assert_eq!(node.status().mining.burn_per_block, 25); - let burned = node.set_burn_per_block(0).unwrap(); - assert!(burned.is_none()); - assert!(!node.status().mining.automatic); - 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, 1); - - node.set_burn_per_block(1).unwrap(); - assert_eq!(node.status().mining.burn_per_block, 1); - assert_eq!(node.status().mining.automatic_burn_fee, 1); - - node.set_automatic_burn(50, 3).unwrap(); - assert_eq!(node.status().mining.burn_per_block, 50); - assert_eq!(node.status().mining.automatic_burn_fee, 3); -} - -#[test] -fn automatic_mining_uses_configured_burn_fee() { - let alice = Wallet::from_seed("auto-fee-burn-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_LUUN); - let mut node = node("alice", alice, allocations); - - let burned = node.set_automatic_burn(50, 3).unwrap().unwrap(); - assert_eq!(burned.amount(), 50); - assert_eq!( - burned.fee(), - burned.serialized_size_bytes().unwrap() as u64 * 3 - ); -} - -#[test] -fn automatic_mining_caps_burn_to_spendable_balance_after_fee() { - let alice = Wallet::from_seed("auto-burn-cap-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), BLOCK_REWARD); - let mut node = NodeCore::new(NodeConfig { - wallet: alice.clone(), - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: BLOCK_REWARD + luun(50), - burn_fee: DEFAULT_FEE_PER_BYTE, - }); - - let outcome = node.automatic_mine_once(1); - - let burned = outcome.burned.as_ref().unwrap(); - let unspent = BLOCK_REWARD - burned.amount() - burned.fee(); - assert!(unspent <= DEFAULT_FEE_PER_BYTE); - assert_eq!( - burned.fee(), - burned.serialized_size_bytes().unwrap() as u64 * DEFAULT_FEE_PER_BYTE - ); - assert!(outcome.block.is_some()); - assert_eq!( - node.ledger().balance_of(alice.address()), - burned.fee() + unspent - ); -} - -#[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(), MICRO_LUUN); - - let mut ledger = Ledger::new(allocations.clone(), 25); - submit_burn(&mut ledger, &alice, 1); - 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(), MICRO_LUUN); - - let mut ledger = Ledger::new(allocations.clone(), 25); - submit_burn(&mut ledger, &alice, 1); - 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 waiting_wallet_gossips_pending_burn_to_selected_leader() { - let alice = Wallet::from_seed("deadlock-alice"); - let bob = Wallet::from_seed("deadlock-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), luun(1_000)); - allocations.insert(bob.address().to_string(), luun(1_000)); - let ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(bob.address(), 1)], 25) - .unwrap(); - - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(bob.address()) - ); - let mut alice_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 1); - let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled( - bob.clone(), - ledger, - true, - DEFAULT_BURN_PER_BLOCK, - MICRO_LUUN, - ); - - let alice_outcome = alice_node.automatic_mine_once(1); - assert!(alice_outcome.burned.is_some()); - assert!(alice_outcome.block.is_none()); - assert!( - alice_outcome - .skipped_reason - .unwrap() - .contains(bob.address()) - ); - assert!(bob_node.ledger().pending().is_empty()); - - for envelope in alice_node.mempool_gossip() { - bob_node.receive(envelope).unwrap(); - } - - assert_eq!(bob_node.ledger().pending().len(), 1); - assert_eq!(bob_node.ledger().pending()[0].sender(), alice.address()); - let bob_outcome = bob_node.automatic_mine_once(1); - assert!(bob_outcome.skipped_reason.is_none()); - assert!(bob_outcome.block.is_some()); -} - -#[test] -fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() { - let alice = Wallet::from_seed("pob-only-finalizer-alice"); - let bob = Wallet::from_seed("pow-only-miner-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 2 * MICRO_LUUN); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), MICRO_LUUN)], - 25, - ) - .unwrap(); - - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(alice.address()) - ); - let mut alice_node = NodeCore::from_ledger_with_burn_fee_and_enabled( - alice, - ledger.clone(), - true, - DEFAULT_BURN_PER_BLOCK, - DEFAULT_FEE_PER_BYTE, - ); - let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled( - bob.clone(), - ledger, - false, - DEFAULT_BURN_PER_BLOCK, - DEFAULT_FEE_PER_BYTE, - ); - bob_node - .set_pow_mining_settings(true, DEFAULT_FEE_PER_BYTE) - .unwrap(); - - let bob_plan = bob_node.prepare_automatic_mining(1); - let mine = bob_plan.pow_mined.expect("B should queue a mine action"); - assert_eq!( - bob_plan.skipped_reason.as_deref(), - Some("automatic mining is off") - ); - assert!(alice_node.ledger().pending().is_empty()); - - for envelope in bob_node.drain_outbox() { - alice_node.receive(envelope).unwrap(); - } - - assert!( - alice_node - .ledger() - .pending() - .iter() - .any(|pending| pending.signature() == mine.signature()), - "A did not receive B's mine action" - ); -} - -#[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(); - submit_burn(&mut ledger, &wallet, 1); - - 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 fallback_finalizer_can_build_block_with_extra_vdf_rounds() { - let alice = Wallet::from_seed("fallback-finalizer-alice"); - let bob = Wallet::from_seed("fallback-finalizer-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), luun(10)); - genesis.insert(bob.address().to_string(), luun(10)); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 25, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != leader) - .unwrap(); - - submit_burn(&mut ledger, fallback, 1); - let block = ledger.mine_next_block(fallback, 1).unwrap(); - - assert_eq!(block.miner, fallback.address()); - assert_eq!(block.finalizer_rank, 1); - assert_eq!(block.vdf_rounds, 50); - ledger.apply_block(block).unwrap(); -} - -#[test] -fn fallback_finalizer_with_primary_vdf_rounds_is_rejected() { - let alice = Wallet::from_seed("fallback-rounds-alice"); - let bob = Wallet::from_seed("fallback-rounds-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), luun(10)); - genesis.insert(bob.address().to_string(), luun(10)); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 25, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != leader) - .unwrap(); - - submit_burn(&mut ledger, fallback, 1); - let mut block = ledger.mine_next_block(fallback, 1).unwrap(); - block.vdf_rounds = 25; - 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!(format!("{error:#}").contains("block VDF rounds are invalid")); -} - -#[test] -fn fork_choice_prefers_primary_finalizer_over_fallback_rank() { - let alice = Wallet::from_seed("fallback-fork-alice"); - let bob = Wallet::from_seed("fallback-fork-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), luun(10)); - genesis.insert(bob.address().to_string(), luun(10)); - let common = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 25, - ) - .unwrap(); - let leader_address = common.expected_leader_for_next_block().unwrap(); - let leader = wallets - .iter() - .copied() - .find(|wallet| wallet.address() == leader_address) - .unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != leader_address) - .unwrap(); - - let mut local = common.clone(); - submit_burn(&mut local, fallback, 1); - let fallback_block = local.mine_next_block(fallback, 1).unwrap(); - assert_eq!(fallback_block.finalizer_rank, 1); - local.apply_block(fallback_block).unwrap(); - - let mut remote = common; - submit_burn(&mut remote, leader, 1); - let leader_block = remote.mine_next_block(leader, 1).unwrap(); - assert_eq!(leader_block.finalizer_rank, 0); - let leader_hash = leader_block.hash.clone(); - remote.apply_block(leader_block).unwrap(); - - assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert_eq!(local.status().tip_hash, leader_hash); -} - -#[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); - - submit_burn(&mut ledger, &wallet, 1); - 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); - - submit_burn(&mut ledger, &wallet, 1); - 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); - - submit_burn(&mut ledger, &wallet, 1); - 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 conflicting_utxo_spends_are_not_accepted_together() { - 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 first = burn_tx(&ledger, &wallet, 3); - let conflicting = burn_tx(&ledger, &wallet, 4); - ledger.submit_transaction(first.clone()).unwrap(); - assert!(!ledger.submit_transaction(conflicting).unwrap()); - assert_eq!(ledger.pending().len(), 1); - - let block = ledger - .prepare_next_block(wallet.address(), 1) - .unwrap() - .finish(&wallet, "test-vdf".to_string()); - assert_eq!(block.transactions.len(), 1); - assert!(block.transactions.contains(&first)); -} - -#[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(), luun(100)); - let alice_ledger = Ledger::new_with_genesis_burns( - genesis, - vec![GenesisBurn::new(alice.address(), MICRO_LUUN)], - 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_with_fee(bob.address(), luun(30), 0) - .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| tx.to() == Some(bob.address()) && tx.amount() == luun(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()), - luun(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(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block7 = network.node_mut("a").unwrap().mine_one_at(7).unwrap(); - mined_by.push(block7.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block8 = network.node_mut("a").unwrap().mine_one_at(8).unwrap(); - mined_by.push(block8.miner.clone()); - network.deliver_until_idle().unwrap(); - - network - .node_mut("b") - .unwrap() - .transfer_with_fee(carol.address(), luun(10), 0) - .unwrap(); - network.node_mut("b").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - assert_eq!( - network - .node("a") - .unwrap() - .ledger() - .expected_leader_for_next_block(), - Some(bob.address().to_string()) - ); - let block9 = network.node_mut("b").unwrap().mine_one_at(9).unwrap(); - mined_by.push(block9.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("c").unwrap().burn(5).unwrap(); - network.deliver_until_idle().unwrap(); - let block10 = network.node_mut("a").unwrap().mine_one_at(10).unwrap(); - mined_by.push(block10.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block11 = network.node_mut("a").unwrap().mine_one_at(11).unwrap(); - mined_by.push(block11.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("b").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block12 = network.node_mut("b").unwrap().mine_one_at(12).unwrap(); - mined_by.push(block12.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 block13 = network.node_mut("c").unwrap().mine_one_at(13).unwrap(); - mined_by.push(block13.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, 13); - 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(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().mine_one_at(4).unwrap(); - network.deliver_until_idle().unwrap(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().mine_one_at(5).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(6).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, - burn_fee: 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); - submit_burn(&mut ledger, &alice, 10); - 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 - .set_automatic_burn_settings(true, DEFAULT_BURN_PER_BLOCK, DEFAULT_FEE_PER_BYTE) - .unwrap(); - 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, .. } - | luun::domain::Transaction::Mine { 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 = transfer_tx(&local, &bob, carol.address(), 7); - 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 = burn_tx(&local, &alice, 1); - 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 = transfer_tx(&local, &bob, alice.address(), 5); - local.submit_transaction(local_transfer.clone()).unwrap(); - - submit_burn(&mut remote, &alice, 1); - let remote_block_1 = remote.mine_next_block(&alice, 1).unwrap(); - remote.apply_block(remote_block_1).unwrap(); - submit_burn(&mut remote, &alice, 1); - 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 - ); -} diff --git a/tests/properties.rs b/tests/properties.rs @@ -1,9 +1,9 @@ use std::collections::{BTreeMap, BTreeSet}; -use luun::{ +use iuna::{ app::{InMemoryNetwork, NodeCore}, domain::{ - Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_LUUN, MINE_REWARD, OutPoint, Transaction, + Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, Wallet, hex_hash, verify_vdf, }, }; @@ -70,8 +70,8 @@ fn genesis_burns(wallets: &[Wallet], amount: Amount) -> Vec<GenesisBurn> { fn property_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) { let wallets = test_wallets(seed, wallet_count); let ledger = Ledger::new_with_genesis_burns( - allocations(&wallets, 250 * MICRO_LUUN), - genesis_burns(&wallets, MICRO_LUUN), + allocations(&wallets, 250 * MICRO_IUNA), + genesis_burns(&wallets, MICRO_IUNA), 1, ) .expect("property genesis is valid"); @@ -81,8 +81,8 @@ fn property_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) { fn single_finalizer_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) { let wallets = test_wallets(seed, wallet_count); let ledger = Ledger::new_with_genesis_burns( - allocations(&wallets, 250 * MICRO_LUUN), - vec![GenesisBurn::new(wallets[0].address(), MICRO_LUUN)], + allocations(&wallets, 250 * MICRO_IUNA), + vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)], 1, ) .expect("single-finalizer property genesis is valid"); @@ -229,7 +229,7 @@ fn seed_reference_genesis_allocations( } utxos.insert( OutPoint { - txid: hex_hash(format!("luun-genesis-allocation:{address}")), + txid: hex_hash(format!("iuna-genesis-allocation:{address}")), index: 0, }, TxOutput { @@ -355,7 +355,7 @@ fn try_random_transaction( match rng.index(5) { 0 => { let (from, to) = random_wallet_pair(rng, wallets); - let amount = rng.amount(5 * MICRO_LUUN); + let amount = rng.amount(5 * MICRO_IUNA); let fee = rng.next_u64() % 4; if let Ok(tx) = ledger.build_transfer(from, to.address(), amount, fee) { let _ = ledger.submit_transaction(tx); @@ -363,7 +363,7 @@ fn try_random_transaction( } 1 => { let wallet = &wallets[rng.index(wallets.len())]; - let amount = rng.amount(3 * MICRO_LUUN); + let amount = rng.amount(3 * MICRO_IUNA); let fee = rng.next_u64() % 4; if let Ok(tx) = ledger.build_burn(wallet, amount, fee) { let _ = ledger.submit_transaction(tx); @@ -377,7 +377,7 @@ fn try_random_transaction( } 3 => { let wallet = &wallets[rng.index(wallets.len())]; - let impossible = (seed + round as u64 + 1) * MICRO_LUUN * 10_000; + let impossible = (seed + round as u64 + 1) * MICRO_IUNA * 10_000; assert!(ledger.build_burn(wallet, impossible, 0).is_err()); } _ => {} @@ -392,7 +392,7 @@ fn try_finalize_next_block(round: usize, wallets: &[Wallet], ledger: &mut Ledger return; }; - if let Ok(tx) = ledger.build_burn(wallet, MICRO_LUUN, 0) { + if let Ok(tx) = ledger.build_burn(wallet, MICRO_IUNA, 0) { let _ = ledger.submit_transaction(tx); } @@ -405,7 +405,7 @@ fn try_finalize_next_block(round: usize, wallets: &[Wallet], ledger: &mut Ledger fn finalize_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) { let burn = ledger - .build_burn(wallet, MICRO_LUUN, 0) + .build_burn(wallet, MICRO_IUNA, 0) .expect("finalizer can build burn"); let _ = ledger .submit_transaction(burn) @@ -453,7 +453,7 @@ fn generated_forks_reorg_only_inside_finality_and_preserve_local_transactions() let mut local = common.clone(); let abandoned = local - .build_transfer(sender, recipient.address(), MICRO_LUUN + rng.amount(5), 0) + .build_transfer(sender, recipient.address(), MICRO_IUNA + rng.amount(5), 0) .expect("abandoned fork transfer builds"); local .submit_transaction(abandoned.clone()) @@ -516,7 +516,7 @@ fn in_memory_network_converges_under_generated_node_actions() { wallet.clone(), joined, true, - MICRO_LUUN, + MICRO_IUNA, 0, ), ); @@ -537,13 +537,13 @@ fn in_memory_network_converges_under_generated_node_actions() { let _ = network .node_mut(&node_id) .expect("node exists") - .transfer_with_fee(recipient, rng.amount(2 * MICRO_LUUN), 0); + .transfer_with_fee(recipient, rng.amount(2 * MICRO_IUNA), 0); } 1 => { let _ = network .node_mut(&node_id) .expect("node exists") - .burn_with_fee(MICRO_LUUN, 0); + .burn_with_fee(MICRO_IUNA, 0); } 2 => { let _ = network @@ -653,7 +653,7 @@ fn deliver_with_chaos( fn receive_chaotic_envelope( network: &mut InMemoryNetwork, id: &str, - envelope: luun::app::GossipEnvelope, + envelope: iuna::app::GossipEnvelope, ) { if let Err(error) = network.node_mut(id).expect("node exists").receive(envelope) { let message = error.to_string(); @@ -696,7 +696,7 @@ fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() wallet.clone(), joined, true, - MICRO_LUUN, + MICRO_IUNA, 0, ), ); @@ -721,7 +721,7 @@ fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() let _ = network .node_mut(&actor_id) .expect("actor node exists") - .transfer_with_fee(recipient, MICRO_LUUN + rng.amount(17), 0); + .transfer_with_fee(recipient, MICRO_IUNA + rng.amount(17), 0); } 1 => { let _ = network @@ -733,7 +733,7 @@ fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() let _ = network .node_mut("n0") .expect("finalizer node exists") - .burn_with_fee(MICRO_LUUN, 0); + .burn_with_fee(MICRO_IUNA, 0); } } diff --git a/www/index.html b/www/index.html @@ -4,8 +4,8 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="color-scheme" content="dark"> - <meta name="description" content="Luun is a low-energy currency prototype where block finalization requires sacrificing coins, while new supply is earned through open proof-of-work."> - <title>Luun Devnet</title> + <meta name="description" content="iuna is a low-energy currency prototype where block finalization requires sacrificing coins, while new supply is earned through open proof-of-work."> + <title>iuna Devnet</title> <style> :root { color-scheme: dark; @@ -201,9 +201,9 @@ <body> <div class="topbar"> <div class="wrap"> - <div class="brand"><span class="mark">L</span><span>Luun</span></div> + <div class="brand"><span class="mark">i</span><span>iuna</span></div> <nav aria-label="Page sections"> - <a href="#what">What is Luun</a> + <a href="#what">What is iuna</a> <a href="#join">Join</a> </nav> </div> @@ -213,11 +213,11 @@ <div class="wrap hero-grid"> <div> <div class="eyebrow"><span class="dot"></span> Devnet prototype</div> - <h1>Luun</h1> + <h1>iuna</h1> <p class="lead">A low-energy currency where block finalization requires sacrificing coins, while new supply is earned through open proof-of-work.</p> - <p>Luun is not a mainnet and not money yet. It is a small L1 lab for learning how a wallet, node, finalizer, miner, mempool, and peer network behave when real people run it on real machines.</p> + <p>iuna is not a mainnet and not money yet. It is a small L1 lab for learning how a wallet, node, finalizer, miner, mempool, and peer network behave when real people run it on real machines.</p> <div class="actions"> - <a class="button primary" href="#what">What is Luun</a> + <a class="button primary" href="#what">What is iuna</a> <a class="button" href="#join">How to join</a> </div> </div> @@ -237,14 +237,14 @@ <section id="what"> <div class="wrap"> <div class="section-head"> - <h2>What Is Luun?</h2> - <p>Luun combines proof-of-burn finalization, a VDF clock, and open proof-of-work issuance.</p> + <h2>What Is iuna?</h2> + <p>iuna combines proof-of-burn finalization, a VDF clock, and open proof-of-work issuance.</p> </div> <div class="grid"> <div class="card"> <span class="tag green">PoB</span> <h3>Proof Of Burn</h3> - <p>Nodes burn LUUN to enter the block lottery. The top-ranked burner finalizes first; lower ranks can take over with extra VDF delay if the first one disappears.</p> + <p>Nodes burn IUNA to enter the block lottery. The top-ranked burner finalizes first; lower ranks can take over with extra VDF delay if the first one disappears.</p> </div> <div class="card"> <span class="tag cyan">VDF</span> @@ -254,7 +254,7 @@ <div class="card"> <span class="tag blue">PoW</span> <h3>Proof Of Work Issuance</h3> - <p>PoW mine actions introduce new LUUN. Miners create 1 LUUN at a time and choose the fee paid to the block finalizer that includes the action.</p> + <p>PoW mine actions introduce new IUNA. Miners create 1 IUNA at a time and choose the fee paid to the block finalizer that includes the action.</p> </div> </div> </div> @@ -264,9 +264,9 @@ <div class="wrap split"> <div> <h2>How To Join</h2> - <p>Ask for a public seed node address, then start Luun with a local data directory and that peer as your join target.</p> - <pre><code>cargo run -- --data-dir .luun --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join seed.example:9444</code></pre> - <p>Open <code>http://127.0.0.1:18661</code>, set a password, back up your recovery phrase, and let the node sync. After you receive LUUN, you can send transactions, burn for block finalization, or enable PoW mine actions.</p> + <p>Ask for a public seed node address, then start iuna with a local data directory and that peer as your join target.</p> + <pre><code>cargo run -- --data-dir .iuna --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join seed.example:9444</code></pre> + <p>Open <code>http://127.0.0.1:18661</code>, set a password, back up your recovery phrase, and let the node sync. After you receive IUNA, you can send transactions, burn for block finalization, or enable PoW mine actions.</p> <p class="note">Keep the management UI local. The P2P port is for node traffic; the wallet UI should stay on <code>127.0.0.1</code>.</p> </div> <aside class="card"> @@ -279,7 +279,7 @@ </main> <section class="footer"> - <div class="wrap">Luun devnet prototype.</div> + <div class="wrap">iuna devnet prototype.</div> </section> </body> </html>