commit 5c536d06b5ebbcf61aa38e99ae59ecfeb0e3ee9f
parent 3dae843f2408ff245ee6eff14dce15fb60002570
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 25 Jul 2026 20:41:13 +0200
Add wallet security and fee-rate mining UI
Diffstat:
| M | Cargo.lock | | | 104 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | Cargo.toml | | | 2 | ++ |
| M | README.md | | | 10 | ++++++---- |
| M | assets/luun-ui.js | | | 144 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ |
| A | docs/index.html | | | 140 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/adapters/config_store.rs | | | 33 | ++++++++++++++++----------------- |
| M | src/adapters/http.rs | | | 293 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- |
| M | src/adapters/wallet_store.rs | | | 324 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | src/app.rs | | | 371 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- |
| M | src/domain.rs | | | 5 | +++++ |
| M | src/main.rs | | | 87 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ |
| M | tests/luun.rs | | | 54 | +++++++++++++++++++++++++++++++++--------------------- |
12 files changed, 1384 insertions(+), 183 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3,6 +3,16 @@
version = 4
[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -122,6 +132,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
+[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -143,6 +188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
+ "rand_core",
"typenum",
]
@@ -191,6 +237,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
+ "subtle",
]
[[package]]
@@ -350,6 +397,15 @@ dependencies = [
]
[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -430,6 +486,15 @@ dependencies = [
]
[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -479,8 +544,10 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
+ "chacha20poly1305",
"ed25519-dalek",
"getrandom 0.2.17",
+ "pbkdf2",
"rusqlite",
"serde",
"serde_json",
@@ -526,6 +593,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -549,6 +622,16 @@ dependencies = [
]
[[package]]
+name = "pbkdf2"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
+[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -577,6 +660,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -942,6 +1036,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -6,8 +6,10 @@ edition = "2024"
[dependencies]
anyhow = "1.0.98"
axum = "0.8.4"
+chacha20poly1305 = "0.10.1"
ed25519-dalek = "2.2.0"
getrandom = "0.2.17"
+pbkdf2 = "0.12.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.10.9"
diff --git a/README.md b/README.md
@@ -81,19 +81,21 @@ Every non-genesis block must consume the selected eligible ticket, include at le
The protocol targets 60-second blocks by retargeting the expected VDF rounds after each block. It uses a rolling average of recent block intervals and only moves the next round count by about 10% per block, so short bursts do not make the delay swing wildly. Every node derives the same next-round count from the validated chain.
-The base block reward is fixed at 100 LUUN, and miners collect transaction fees on top. The automatic burn setting has a burn amount and a fee; the burn amount is the ticket weight, while the fee is paid to the miner that includes it. The miner includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. The default burn is 0 LUUN per block with a 1-LUUN fee, 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 reward is fixed at 100 LUUN, and miners collect transaction fees on top. 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 miner that includes it. The miner includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. The default burn is 0 LUUN per block 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 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. New wallet files store a 24-word recovery phrase and the derived Ed25519 public key address.
+Luun creates a new wallet file the first time a node starts or joins a chain. By default it lives at `.luun/wallet.json`, or at `<data-dir>/wallet.json` when `--data-dir` is set. Pass `--wallet path/to/wallet.json` to choose a specific wallet file.
-There is no default wallet seed in the binary. Keep the wallet file private; it contains the local wallet seed used to derive the address.
+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.
+
+There is no default wallet seed in the binary. Keep the wallet file and recovery phrase private.
## Node Config
-Luun stores UI setup state, configured peers, the configured automatic burn rate, and the automatic burn fee in `<data-dir>/config.json`. If `setup_complete` is false, the management UI opens the initial setup screen for wallet and peer setup. Completing setup and later runtime changes write the file through the HTTP API, so the choices follow the node data directory instead of a browser session.
+Luun stores UI setup state, configured peers, 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
diff --git a/assets/luun-ui.js b/assets/luun-ui.js
@@ -30,17 +30,19 @@ window.luunApp = function luunApp() {
setupFeedback: null,
burnAmount: 0,
burnAmountDraft: 0,
- burnFee: 1000000,
- burnFeeDraft: "1",
+ burnFee: 1,
+ burnFeeDraft: "0.000001",
miningEnabled: false,
powMiningEnabled: false,
- powMineFee: 10000,
- powMineFeeDraft: "0.01",
+ powMineFee: 1,
+ powMineFeeDraft: "0.000001",
powMineFeeDirty: false,
burnAmountDirty: false,
transferTo: "",
transferAmount: null,
- transferFee: "1",
+ transferFee: "0.000001",
+ feeEstimates: { transfer: null, burn: null, mine: null },
+ feeEstimateTimer: null,
showSendAdvanced: false,
selectedTransferUtxos: [],
peerAddress: "",
@@ -351,7 +353,7 @@ window.luunApp = function luunApp() {
const [config, status, blocks, walletTxs, walletUtxos, mempool, peers, p2pMetrics] = await Promise.all([
this.fetchJson("/api/config"),
this.fetchJson("/api/status"),
- this.fetchJson("/api/blocks"),
+ this.fetchJson("/api/blocks?limit=30"),
this.fetchJson("/api/wallet/transactions"),
this.fetchJson("/api/wallet/utxos"),
this.fetchJson("/api/mempool"),
@@ -382,6 +384,7 @@ window.luunApp = function luunApp() {
this.powMineFeeDraft = this.amountLabel(this.powMineFee);
}
this.lastUpdated = new Date();
+ this.scheduleFeeEstimates();
} catch (error) {
if (String(error.message || "").includes("401")) {
await this.refreshAuth();
@@ -563,17 +566,93 @@ window.luunApp = function luunApp() {
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.parseLuunAmount(this.burnFeeDraft);
+ 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 },
+ { enabled: this.miningEnabled, amount, fee_per_byte: fee },
this.miningEnabled
- ? `Mining on: ${this.amountLabel(amount)} LUUN per block with ${this.amountLabel(fee)} fee`
+ ? `Mining on: ${this.amountLabel(amount)} LUUN per block with ${this.amountLabel(fee)} per byte`
: `Mining settings saved while off`
);
this.burnAmountDirty = false;
@@ -588,7 +667,7 @@ window.luunApp = function luunApp() {
const previous = this.miningEnabled;
try {
const amount = this.parseLuunAmount(this.burnAmountDraft);
- const fee = this.parseLuunAmount(this.burnFeeDraft);
+ 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 mining on");
@@ -596,7 +675,7 @@ window.luunApp = function luunApp() {
this.miningEnabled = enabled;
await this.postForm(
"/api/settings/burn-per-block",
- { enabled, amount, fee },
+ { enabled, amount, fee_per_byte: fee },
enabled ? "Mining turned on" : "Mining turned off"
);
this.burnAmountDirty = false;
@@ -611,11 +690,11 @@ window.luunApp = function luunApp() {
async setPowMiningEnabled(enabled) {
const previous = this.powMiningEnabled;
try {
- const fee = this.parseLuunAmount(this.powMineFeeDraft);
+ const fee = this.parseLuunAmountRequired(this.powMineFeeDraft, "Mine fee per byte is required");
this.powMiningEnabled = enabled;
await this.postForm(
"/api/settings/pow-mining",
- { enabled, fee },
+ { enabled, fee_per_byte: fee },
enabled ? "PoW mining turned on" : "PoW mining turned off"
);
this.powMineFeeDirty = false;
@@ -628,13 +707,13 @@ window.luunApp = function luunApp() {
async savePowMining() {
try {
- const fee = this.parseLuunAmount(this.powMineFeeDraft);
+ 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 },
+ { enabled: this.powMiningEnabled, fee_per_byte: fee },
this.powMiningEnabled
- ? `Mine fee set to ${this.amountLabel(fee)} LUUN`
+ ? `Mine fee rate set to ${this.amountLabel(fee)} LUUN per byte`
: `Mine settings saved while off`
);
this.powMineFeeDirty = false;
@@ -658,7 +737,7 @@ window.luunApp = function luunApp() {
powMineNetReward() {
const reward = Math.max(0, Math.trunc(Number(this.status.chain?.mine_reward ?? 0)));
- return Math.max(0, reward - this.powMineFeeValue());
+ return Math.max(0, reward - (this.feeEstimates.mine?.fee ?? this.powMineFeeValue()));
},
amountLabel(value) {
@@ -682,20 +761,29 @@ window.luunApp = function luunApp() {
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.parseLuunAmount(this.transferFee);
+ 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, utxos: this.selectedTransferUtxos.join("\n") },
- `Queued transfer of ${this.amountLabel(amount)} LUUN to ${recipient} with ${this.amountLabel(fee)} fee`
+ { 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");
}
@@ -845,10 +933,12 @@ window.luunApp = function luunApp() {
selectAllTransferUtxos() {
this.selectedTransferUtxos = this.walletUtxos.map((utxo) => this.utxoOutpoint(utxo));
+ this.scheduleFeeEstimates();
},
clearTransferUtxos() {
this.selectedTransferUtxos = [];
+ this.scheduleFeeEstimates();
},
selectedTransferUtxoTotal() {
@@ -859,7 +949,7 @@ window.luunApp = function luunApp() {
},
transferRequiredTotal() {
- return this.parseLuunAmount(this.transferAmount) + this.parseLuunAmount(this.transferFee);
+ return this.parseLuunAmount(this.transferAmount) + Number(this.feeEstimates.transfer?.fee || 0);
},
selectedTransferUtxosCoverTransfer() {
@@ -892,6 +982,18 @@ window.luunApp = function luunApp() {
.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;
},
diff --git a/docs/index.html b/docs/index.html
@@ -0,0 +1,140 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Luun</title>
+ <style>
+ :root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #0f1012;
+ color: #edf2f5;
+ }
+ * { box-sizing: border-box; }
+ body { margin: 0; background: #0f1012; color: #edf2f5; }
+ a { color: #d5f55f; }
+ code { color: #c7f5ea; overflow-wrap: anywhere; }
+ pre { margin: 0; overflow-x: auto; border: 1px solid #2a3035; border-radius: 8px; padding: 14px; background: #111316; }
+ .hero { min-height: 78vh; display: grid; align-items: end; padding: 56px 22px 28px; background: linear-gradient(180deg, #15171a 0%, #0f1012 100%); }
+ .wrap { width: min(980px, 100%); margin: 0 auto; }
+ .mark { width: 54px; height: 54px; display: grid; place-items: center; border-radius: 8px; background: #d5f55f; color: #11140c; font-size: 28px; font-weight: 950; }
+ h1 { margin: 24px 0 12px; font-size: clamp(44px, 9vw, 96px); line-height: .95; letter-spacing: 0; }
+ h2 { margin: 0 0 12px; font-size: 25px; }
+ h3 { margin: 0 0 10px; font-size: 17px; }
+ p { max-width: 700px; color: #b8c2c8; font-size: 18px; line-height: 1.55; }
+ .lead { color: #edf2f5; font-size: 22px; }
+ .next { margin-top: 52px; color: #8d989f; font-size: 13px; font-weight: 800; text-transform: uppercase; }
+ section { padding: 34px 22px; border-top: 1px solid #22282d; }
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin-top: 18px; }
+ .card { border: 1px solid #2a3035; border-radius: 8px; padding: 16px; background: #181b1f; }
+ .card p { margin: 0; font-size: 15px; }
+ .steps { display: grid; gap: 14px; margin-top: 18px; }
+ .step { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; align-items: start; }
+ .num { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 999px; background: #d5f55f; color: #11140c; font-weight: 900; }
+ .note { border-left: 3px solid #d5f55f; padding-left: 14px; color: #cbd3d7; }
+ .footer { padding-bottom: 56px; color: #8d989f; }
+ @media (max-width: 640px) {
+ .hero { min-height: 70vh; }
+ p { font-size: 16px; }
+ .lead { font-size: 19px; }
+ .step { grid-template-columns: 1fr; }
+ }
+ </style>
+</head>
+<body>
+ <header class="hero">
+ <div class="wrap">
+ <div class="mark">L</div>
+ <h1>Luun</h1>
+ <p class="lead">A small experimental currency where blocks are finalized by burning LUUN, while new coins are introduced by proof-of-work mine actions.</p>
+ <p>Luun is not a mainnet and not money yet. It is a devnet prototype for learning how a wallet, node, miner, mempool, and peer network behave when real people run it on real machines.</p>
+ <div class="next">Start a node below</div>
+ </div>
+ </header>
+
+ <section>
+ <div class="wrap">
+ <h2>What Makes It Different</h2>
+ <div class="grid">
+ <div class="card">
+ <h3>Burn To Finalize</h3>
+ <p>Nodes burn LUUN to enter the block lottery. The selected burner finalizes the next block and earns the byte-priced fees inside it.</p>
+ </div>
+ <div class="card">
+ <h3>Mine To Issue</h3>
+ <p>Proof-of-work mine actions introduce new LUUN. A miner chooses a fee rate for the finalizer; the rest of the 1 LUUN reward goes to the miner.</p>
+ </div>
+ <div class="card">
+ <h3>Friendly Devnet</h3>
+ <p>The current network is for testing with people you trust. Expect resets, bugs, and protocol changes.</p>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section>
+ <div class="wrap">
+ <h2>Run The First Node</h2>
+ <p>Clone the repo, build it with Cargo, and start a fresh chain from an empty data directory.</p>
+ <div class="steps">
+ <div class="step">
+ <div class="num">1</div>
+ <div>
+ <h3>Start Genesis</h3>
+ <pre><code>cargo run -- --genesis --data-dir .luun-devnet --p2p 0.0.0.0:9444 --http 127.0.0.1:18661</code></pre>
+ </div>
+ </div>
+ <div class="step">
+ <div class="num">2</div>
+ <div>
+ <h3>Open The Local UI</h3>
+ <p>Go to <code>http://127.0.0.1:18661</code>, set a password, write down the recovery phrase, and finish setup. The wallet file is encrypted with that password.</p>
+ </div>
+ </div>
+ <div class="step">
+ <div class="num">3</div>
+ <div>
+ <h3>Share Your P2P Address</h3>
+ <p>Give friends your public host and P2P port, for example <code>your-host.example:9444</code>. Keep the management UI bound to <code>127.0.0.1</code>.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section>
+ <div class="wrap">
+ <h2>Join A Friend</h2>
+ <p>Use a separate data directory and join the seed node your friend gives you.</p>
+ <pre><code>cargo run -- --data-dir .luun-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-friend-host:9444</code></pre>
+ <p>After joining, open the local UI, set your password, back up your recovery phrase, and wait for your friend to send you LUUN. Then you can send transactions, burn for block leadership, or enable PoW mine actions.</p>
+ </div>
+ </section>
+
+ <section>
+ <div class="wrap">
+ <h2>Important Safety Notes</h2>
+ <div class="grid">
+ <div class="card">
+ <h3>Do Not Expose The UI</h3>
+ <p>The management UI is password protected, but it should stay local. Use SSH tunneling or a private network if you need remote access.</p>
+ </div>
+ <div class="card">
+ <h3>Back Up The Phrase</h3>
+ <p>The encrypted wallet protects the file at rest. The recovery phrase is still the thing that restores the wallet.</p>
+ </div>
+ <div class="card">
+ <h3>Expect Resets</h3>
+ <p>This is a devnet. Chain state can be reset, protocol rules can change, and LUUN has no real-world value.</p>
+ </div>
+ </div>
+ <p class="note">For more technical details, see the README in the repository.</p>
+ </div>
+ </section>
+
+ <section class="footer">
+ <div class="wrap">Luun devnet prototype.</div>
+ </section>
+</body>
+</html>
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -9,11 +9,11 @@ use std::{
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
-use crate::domain::{Amount, DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE, MICRO_LUUN};
+use crate::domain::{Amount, DEFAULT_FEE_PER_BYTE, MICRO_LUUN};
const CONFIG_FILE_VERSION: u32 = 1;
const AMOUNT_UNIT_MICROLUUN: &str = "microluun";
-pub const DEFAULT_BURN_FEE: Amount = DEFAULT_TRANSACTION_FEE;
+pub const DEFAULT_BURN_FEE: Amount = DEFAULT_FEE_PER_BYTE;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct UiConfig {
@@ -37,7 +37,7 @@ impl Default for UiConfig {
pow_mining_enabled: false,
burn_per_block: 0,
burn_fee: DEFAULT_BURN_FEE,
- pow_mine_fee: DEFAULT_MINE_FEE,
+ pow_mine_fee: DEFAULT_FEE_PER_BYTE,
peers: Vec::new(),
}
}
@@ -57,18 +57,14 @@ struct ConfigFile {
pow_mining_enabled: bool,
#[serde(default)]
burn_per_block: Amount,
- #[serde(default = "default_burn_fee")]
- burn_fee: Amount,
+ #[serde(default)]
+ burn_fee: Option<Amount>,
#[serde(default)]
pow_mine_fee: Option<Amount>,
#[serde(default)]
peers: Vec<String>,
}
-fn default_burn_fee() -> Amount {
- 1
-}
-
pub fn load_or_create(path: &Path) -> Result<UiConfig> {
if path.exists() {
return load(path);
@@ -93,7 +89,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
mining_enabled: Some(config.mining_enabled),
pow_mining_enabled: config.pow_mining_enabled,
burn_per_block: config.burn_per_block,
- burn_fee: config.burn_fee,
+ burn_fee: Some(config.burn_fee),
pow_mine_fee: Some(config.pow_mine_fee),
peers: config.peers.clone(),
};
@@ -132,11 +128,14 @@ fn load(path: &Path) -> Result<UiConfig> {
mining_enabled: stored.mining_enabled.unwrap_or(stored.burn_per_block > 0),
pow_mining_enabled: stored.pow_mining_enabled,
burn_per_block: stored.burn_per_block.saturating_mul(scale),
- burn_fee: stored.burn_fee.saturating_mul(scale),
+ burn_fee: stored
+ .burn_fee
+ .map(|fee| fee.saturating_mul(scale))
+ .unwrap_or(DEFAULT_BURN_FEE),
pow_mine_fee: stored
.pow_mine_fee
.map(|fee| fee.saturating_mul(scale))
- .unwrap_or(DEFAULT_MINE_FEE),
+ .unwrap_or(DEFAULT_FEE_PER_BYTE),
peers: stored.peers,
})
}
@@ -159,7 +158,7 @@ mod tests {
use crate::domain::MICRO_LUUN;
- use super::{DEFAULT_BURN_FEE, DEFAULT_MINE_FEE, UiConfig, load_or_create, save};
+ use super::{DEFAULT_BURN_FEE, DEFAULT_FEE_PER_BYTE, UiConfig, load_or_create, save};
#[test]
fn creates_default_config_file() {
@@ -177,8 +176,8 @@ mod tests {
assert!(stored.contains("\"mining_enabled\": false"));
assert!(stored.contains("\"pow_mining_enabled\": false"));
assert!(stored.contains("\"burn_per_block\": 0"));
- assert!(stored.contains("\"burn_fee\": 1000000"));
- assert!(stored.contains("\"pow_mine_fee\": 10000"));
+ assert!(stored.contains("\"burn_fee\": 1"));
+ assert!(stored.contains("\"pow_mine_fee\": 1"));
assert!(stored.contains("\"peers\": []"));
}
@@ -247,7 +246,7 @@ mod tests {
assert!(!config.pow_mining_enabled);
assert_eq!(config.burn_per_block, 0);
assert_eq!(config.burn_fee, DEFAULT_BURN_FEE);
- assert_eq!(config.pow_mine_fee, DEFAULT_MINE_FEE);
+ assert_eq!(config.pow_mine_fee, DEFAULT_FEE_PER_BYTE);
assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
}
@@ -273,6 +272,6 @@ mod tests {
assert!(config.mining_enabled);
assert_eq!(config.burn_per_block, 2 * MICRO_LUUN);
assert_eq!(config.burn_fee, MICRO_LUUN);
- assert_eq!(config.pow_mine_fee, DEFAULT_MINE_FEE);
+ assert_eq!(config.pow_mine_fee, DEFAULT_FEE_PER_BYTE);
}
}
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -28,7 +28,7 @@ use crate::{
p2p::{GossipNetwork, P2pMetrics},
wallet_store,
},
- app::{NodeStatus, PeerInfo, SharedNode, SharedPeerBook},
+ app::{FeeEstimate, NodeStatus, PeerInfo, SharedNode, SharedPeerBook},
domain::{Amount, Block, OutPoint, Transaction, TxInput, TxOutput, hex_hash},
};
@@ -47,7 +47,13 @@ struct HttpState {
ui_config: Arc<Mutex<UiConfig>>,
config_path: PathBuf,
wallet_path: PathBuf,
- auth_sessions: Arc<Mutex<BTreeMap<String, u64>>>,
+ auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>,
+}
+
+#[derive(Clone)]
+struct AuthSession {
+ expires_at: u64,
+ wallet_password: String,
}
#[derive(Debug, Deserialize)]
@@ -65,20 +71,20 @@ struct AuthStatusResponse {
struct BurnSettingsForm {
enabled: Option<bool>,
amount: Amount,
- fee: Amount,
+ fee_per_byte: Option<Amount>,
}
#[derive(Debug, Deserialize)]
struct PowMiningForm {
enabled: bool,
- fee: Amount,
+ fee_per_byte: Option<Amount>,
}
#[derive(Debug, Deserialize)]
struct TransferForm {
to: String,
amount: Amount,
- fee: Option<Amount>,
+ fee_per_byte: Option<Amount>,
#[serde(default)]
utxos: String,
}
@@ -111,6 +117,15 @@ struct ActionResponse {
}
#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct FeeEstimateResponse {
+ ok: bool,
+ error: Option<String>,
+ bytes: Option<usize>,
+ fee: Option<Amount>,
+}
+
+#[derive(Debug, Serialize)]
struct WalletSetupResponse {
ok: bool,
error: Option<String>,
@@ -155,6 +170,7 @@ struct UiBlock {
timestamp_ms: u64,
miner: String,
reward: Amount,
+ total_fees: Amount,
vdf_rounds: u32,
vdf_output: String,
leader_proof: Option<crate::domain::LeaderProof>,
@@ -221,6 +237,12 @@ pub async fn serve(
.route("/api/wallet/import", post(api_wallet_import_form))
.route("/api/wallet/transactions", get(api_wallet_transactions))
.route("/api/wallet/utxos", get(api_wallet_utxos))
+ .route(
+ "/api/fee-estimate/transfer",
+ post(api_transfer_fee_estimate_form),
+ )
+ .route("/api/fee-estimate/burn", post(api_burn_fee_estimate_form))
+ .route("/api/fee-estimate/mine", post(api_mine_fee_estimate_form))
.route("/api/mempool", get(api_mempool))
.route("/api/peers", get(api_peers).post(api_peer_form))
.route("/api/p2p/metrics", get(api_p2p_metrics))
@@ -307,10 +329,22 @@ async fn request_is_authenticated(state: &HttpState, headers: &HeaderMap) -> boo
let token_hash = session_token_hash(token);
let now = now_ms();
let mut sessions = state.auth_sessions.lock().await;
- sessions.retain(|_, expires_at| *expires_at > now);
+ sessions.retain(|_, session| session.expires_at > now);
sessions
.get(&token_hash)
- .is_some_and(|expires_at| *expires_at > now)
+ .is_some_and(|session| session.expires_at > now)
+}
+
+async fn wallet_password_for_request(state: &HttpState, headers: &HeaderMap) -> Option<String> {
+ let token = auth_cookie(headers)?;
+ let token_hash = session_token_hash(token);
+ let now = now_ms();
+ let mut sessions = state.auth_sessions.lock().await;
+ sessions.retain(|_, session| session.expires_at > now);
+ sessions
+ .get(&token_hash)
+ .filter(|session| session.expires_at > now)
+ .map(|session| session.wallet_password.clone())
}
async fn api_auth_status(
@@ -394,8 +428,11 @@ async fn api_config(State(state): State<HttpState>) -> Json<UiConfig> {
Json(state.ui_config.lock().await.clone())
}
-async fn api_wallet_setup(State(state): State<HttpState>) -> Json<WalletSetupResponse> {
- wallet_setup_json(wallet_setup_response(&state).await)
+async fn api_wallet_setup(
+ State(state): State<HttpState>,
+ headers: HeaderMap,
+) -> Json<WalletSetupResponse> {
+ wallet_setup_json(wallet_setup_response(&state, &headers).await)
}
async fn api_mempool(State(state): State<HttpState>) -> Json<Vec<UiTransaction>> {
@@ -467,15 +504,40 @@ async fn api_config_form(
action_json(config_store::save(&state.config_path, &config))
}
-async fn api_wallet_generate_form(State(state): State<HttpState>) -> Json<WalletSetupResponse> {
- wallet_setup_json(replace_setup_wallet_with_generated_seed(&state).await)
+async fn api_wallet_generate_form(
+ State(state): State<HttpState>,
+ headers: HeaderMap,
+) -> Json<WalletSetupResponse> {
+ wallet_setup_json(replace_setup_wallet_with_generated_seed(&state, &headers).await)
}
async fn api_wallet_import_form(
State(state): State<HttpState>,
+ headers: HeaderMap,
Form(form): Form<SeedPhraseForm>,
) -> Json<WalletSetupResponse> {
- wallet_setup_json(import_setup_wallet_seed(&state, &form.seed_phrase).await)
+ wallet_setup_json(import_setup_wallet_seed(&state, &headers, &form.seed_phrase).await)
+}
+
+async fn api_transfer_fee_estimate_form(
+ State(state): State<HttpState>,
+ Form(form): Form<TransferForm>,
+) -> Json<FeeEstimateResponse> {
+ fee_estimate_json(estimate_transfer_fee(&state, form).await)
+}
+
+async fn api_burn_fee_estimate_form(
+ State(state): State<HttpState>,
+ Form(form): Form<BurnSettingsForm>,
+) -> Json<FeeEstimateResponse> {
+ fee_estimate_json(estimate_burn_fee(&state, form).await)
+}
+
+async fn api_mine_fee_estimate_form(
+ State(state): State<HttpState>,
+ Form(form): Form<PowMiningForm>,
+) -> Json<FeeEstimateResponse> {
+ fee_estimate_json(estimate_mine_fee(&state, form).await)
}
async fn api_burn_per_block_form(
@@ -483,7 +545,10 @@ async fn api_burn_per_block_form(
Form(form): Form<BurnSettingsForm>,
) -> Json<ActionResponse> {
let enabled = form.enabled.unwrap_or(form.amount > 0);
- let result = set_burn_settings(&state, enabled, form.amount, form.fee).await;
+ let result = match required_fee_per_byte_burn(&form) {
+ Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await,
+ Err(error) => Err(error),
+ };
action_json(result)
}
@@ -491,7 +556,10 @@ async fn api_pow_mining_form(
State(state): State<HttpState>,
Form(form): Form<PowMiningForm>,
) -> Json<ActionResponse> {
- let result = set_pow_mining(&state, form.enabled, form.fee).await;
+ let result = match required_fee_per_byte_mine(&form) {
+ Ok(fee_per_byte) => set_pow_mining(&state, form.enabled, fee_per_byte).await,
+ Err(error) => Err(error),
+ };
action_json(result)
}
@@ -500,7 +568,11 @@ async fn burn_per_block_form(
Form(form): Form<BurnSettingsForm>,
) -> Response {
let enabled = form.enabled.unwrap_or(form.amount > 0);
- match set_burn_settings(&state, enabled, form.amount, form.fee).await {
+ let result = match required_fee_per_byte_burn(&form) {
+ Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await,
+ Err(error) => Err(error),
+ };
+ match result {
Ok(_) => Redirect::to("/").into_response(),
Err(error) => api_error(error).into_response(),
}
@@ -610,12 +682,16 @@ async fn add_peer(state: &HttpState, peer: String) -> Result<()> {
config_store::save(&state.config_path, &config)
}
-async fn wallet_setup_response(state: &HttpState) -> Result<WalletSetupResponse> {
+async fn wallet_setup_response(
+ state: &HttpState,
+ headers: &HeaderMap,
+) -> Result<WalletSetupResponse> {
let setup_complete = state.ui_config.lock().await.setup_complete;
+ let password = wallet_password_for_request(state, headers).await;
let seed_phrase = if setup_complete {
None
} else {
- wallet_store::setup_seed_phrase(&state.wallet_path)?
+ wallet_store::setup_seed_phrase_with_password(&state.wallet_path, password.as_deref())?
};
let address = state.node.lock().await.wallet_address().to_string();
Ok(WalletSetupResponse {
@@ -744,6 +820,7 @@ fn ui_block(block: Block, outputs: &BTreeMap<OutPoint, TxOutput>) -> UiBlock {
timestamp_ms: block.timestamp_ms,
miner: block.miner,
reward: block.reward,
+ total_fees: block.reward,
vdf_rounds: block.vdf_rounds,
vdf_output: block.vdf_output,
leader_proof: block.leader_proof,
@@ -941,10 +1018,14 @@ fn reward_outpoint(block_hash: &str) -> OutPoint {
async fn replace_setup_wallet_with_generated_seed(
state: &HttpState,
+ headers: &HeaderMap,
) -> Result<WalletSetupResponse> {
ensure_wallet_setup_open(state).await?;
+ let password = wallet_password_for_request(state, headers)
+ .await
+ .context("wallet password session is required")?;
let (wallet, seed_phrase) =
- wallet_store::replace_with_generated_seed_phrase(&state.wallet_path)?;
+ wallet_store::replace_with_generated_seed_phrase_encrypted(&state.wallet_path, &password)?;
let address = wallet.address().to_string();
state.node.lock().await.replace_wallet(wallet);
Ok(WalletSetupResponse {
@@ -958,10 +1039,18 @@ async fn replace_setup_wallet_with_generated_seed(
async fn import_setup_wallet_seed(
state: &HttpState,
+ headers: &HeaderMap,
seed_phrase: &str,
) -> Result<WalletSetupResponse> {
ensure_wallet_setup_open(state).await?;
- let wallet = wallet_store::replace_with_imported_seed_phrase(&state.wallet_path, seed_phrase)?;
+ let password = wallet_password_for_request(state, headers)
+ .await
+ .context("wallet password session is required")?;
+ let wallet = wallet_store::replace_with_imported_seed_phrase_encrypted(
+ &state.wallet_path,
+ seed_phrase,
+ &password,
+ )?;
let address = wallet.address().to_string();
state.node.lock().await.replace_wallet(wallet);
Ok(WalletSetupResponse {
@@ -1003,15 +1092,11 @@ fn dev_seed_verify_bypass_allowed(env_present: bool) -> bool {
}
async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> {
- let (to, amount, fee, selected_utxos) = validate_transfer_form(form)?;
+ let (to, amount, fee_per_byte, selected_utxos) = validate_transfer_form(form)?;
let result = {
let mut node = state.node.lock().await;
- let result = if selected_utxos.is_empty() {
- node.transfer_with_fee(to, amount, fee)
- } else {
- node.transfer_with_fee_spending(to, amount, fee, &selected_utxos)
- };
+ let result = node.transfer_with_fee_rate(to, amount, fee_per_byte, &selected_utxos);
let outbox = node.drain_outbox();
(result, outbox)
};
@@ -1030,7 +1115,7 @@ fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount,
if form.amount == 0 {
bail!("amount must be greater than zero");
}
- let fee = form.fee.context("fee is required")?;
+ let fee = required_fee_per_byte_transfer(&form)?;
let selected_utxos = form
.utxos
.lines()
@@ -1042,6 +1127,61 @@ fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount,
Ok((to.to_string(), form.amount, fee, selected_utxos))
}
+async fn estimate_transfer_fee(state: &HttpState, form: TransferForm) -> Result<FeeEstimate> {
+ let (to, amount, fee_per_byte, selected_utxos) = validate_transfer_form(form)?;
+ state
+ .node
+ .lock()
+ .await
+ .estimate_transfer_fee(to, amount, fee_per_byte, &selected_utxos)
+}
+
+async fn estimate_burn_fee(state: &HttpState, form: BurnSettingsForm) -> Result<FeeEstimate> {
+ let fee_per_byte = required_fee_per_byte_burn(&form)?;
+ if form.amount == 0 {
+ bail!("amount must be greater than zero");
+ }
+ state
+ .node
+ .lock()
+ .await
+ .estimate_burn_fee(form.amount, fee_per_byte)
+}
+
+async fn estimate_mine_fee(state: &HttpState, form: PowMiningForm) -> Result<FeeEstimate> {
+ let fee_per_byte = required_fee_per_byte_mine(&form)?;
+ state.node.lock().await.estimate_mine_fee(fee_per_byte)
+}
+
+fn required_fee_per_byte_transfer(form: &TransferForm) -> Result<Amount> {
+ form.fee_per_byte.context("fee per byte is required")
+}
+
+fn required_fee_per_byte_burn(form: &BurnSettingsForm) -> Result<Amount> {
+ form.fee_per_byte.context("fee per byte is required")
+}
+
+fn required_fee_per_byte_mine(form: &PowMiningForm) -> Result<Amount> {
+ form.fee_per_byte.context("fee per byte is required")
+}
+
+fn fee_estimate_json(result: Result<FeeEstimate>) -> Json<FeeEstimateResponse> {
+ match result {
+ Ok(estimate) => Json(FeeEstimateResponse {
+ ok: true,
+ error: None,
+ bytes: Some(estimate.bytes),
+ fee: Some(estimate.fee),
+ }),
+ Err(error) => Json(FeeEstimateResponse {
+ ok: false,
+ error: Some(format!("{error:#}")),
+ bytes: None,
+ fee: None,
+ }),
+ }
+}
+
fn parse_outpoint(value: &str) -> Result<OutPoint> {
let (txid, index) = value
.rsplit_once(':')
@@ -1096,7 +1236,10 @@ async fn setup_auth_password(state: &HttpState, password: &str) -> Result<String
config.auth_password_hash = Some(hash_password(password)?);
config_store::save(&state.config_path, &config)?;
drop(config);
- create_session_cookie(state).await
+ wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?;
+ let wallet = wallet_store::load_with_password(&state.wallet_path, password)?;
+ state.node.lock().await.replace_wallet(wallet);
+ create_session_cookie(state, password).await
}
async fn login_auth_password(state: &HttpState, password: &str) -> Result<String> {
@@ -1110,7 +1253,10 @@ async fn login_auth_password(state: &HttpState, password: &str) -> Result<String
if !verify_password(password, &hash)? {
bail!("invalid password");
}
- create_session_cookie(state).await
+ wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?;
+ let wallet = wallet_store::load_with_password(&state.wallet_path, password)?;
+ state.node.lock().await.replace_wallet(wallet);
+ create_session_cookie(state, password).await
}
fn validate_password(password: &str) -> Result<()> {
@@ -1123,15 +1269,17 @@ fn validate_password(password: &str) -> Result<()> {
Ok(())
}
-async fn create_session_cookie(state: &HttpState) -> Result<String> {
+async fn create_session_cookie(state: &HttpState, password: &str) -> Result<String> {
let token = random_hex(32)?;
let token_hash = session_token_hash(&token);
let expires_at = now_ms().saturating_add(AUTH_SESSION_TTL_MS);
- state
- .auth_sessions
- .lock()
- .await
- .insert(token_hash, expires_at);
+ state.auth_sessions.lock().await.insert(
+ token_hash,
+ AuthSession {
+ expires_at,
+ wallet_password: password.to_string(),
+ },
+ );
Ok(format!(
"{AUTH_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}",
AUTH_SESSION_TTL_MS / 1000
@@ -1412,7 +1560,9 @@ const INDEX_HTML: &str = r#"<!doctype html>
.mine-action-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.mine-settings-form { display: grid; gap: 10px; }
.mine-fee-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
+ .fee-preview { flex-basis: 100%; color: #9eb3bc; font-size: 12px; font-weight: 700; }
.mine-stats { display: grid; grid-template-columns: repeat(4, minmax(112px, 1fr)); gap: 8px; min-width: 0; }
+ .fee-history { grid-template-columns: repeat(3, minmax(112px, 1fr)); margin-top: 12px; }
.mine-stat { min-width: 0; border: 1px solid #2f363c; border-radius: 8px; padding: 9px 10px; background: #111316; }
.mine-stat-label { display: flex; gap: 5px; align-items: center; color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; }
.mine-stat-value { margin-top: 5px; color: #dce4e7; font-size: 14px; font-weight: 850; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
@@ -1523,7 +1673,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/luun-ui.js?v=47"></script>
+ <script defer src="/assets/luun-ui.js?v=49"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="luunApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -1575,9 +1725,10 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="panel">
<h3>Send</h3>
<form @submit.prevent="sendTransfer">
- <label>Recipient<input x-model="transferTo" autocomplete="off" required></label>
- <label>Amount<input x-model="transferAmount" type="number" min="0.000001" step="0.000001" required></label>
- <label>Fee<input x-model="transferFee" type="number" min="0" step="0.000001" required></label>
+ <label>Recipient<input x-model="transferTo" @input="scheduleFeeEstimates" autocomplete="off" required></label>
+ <label>Amount<input x-model="transferAmount" @input="scheduleFeeEstimates" type="number" min="0.000001" step="0.000001" required></label>
+ <label>Fee / byte<input x-model="transferFee" @input="scheduleFeeEstimates" type="number" min="0" step="0.000001" required></label>
+ <div class="fee-preview" x-text="feeEstimateLabel('transfer')"></div>
<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>
@@ -1594,7 +1745,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
<template x-for="utxo in walletUtxos" :key="utxoOutpoint(utxo)">
<label class="send-utxo-option">
- <input type="checkbox" :value="utxoOutpoint(utxo)" x-model="selectedTransferUtxos">
+ <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>
<code class="tx-value hash" x-text="utxoOutpoint(utxo)"></code>
@@ -1670,11 +1821,26 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="panel-description">Burn LUUN to compete for block leadership. Winning burns produce 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" type="number" min="0" step="0.000001"></label>
- <label>Fee<input x-model="burnFeeDraft" @input="burnAmountDirty = true" type="number" min="0" step="0.000001"></label>
+ <label>LUUN 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>
+ <div class="fee-preview" x-text="feeEstimateLabel('burn')"></div>
</form>
+ <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>
+ <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>
+ <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>
+ </div>
</div>
<div class="panel">
<h3>Mine</h3>
@@ -1688,7 +1854,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
<div class="mine-stat">
<div class="mine-stat-label">Finalizer fee</div>
- <div class="mine-stat-value">LUUN <span x-text="amountLabel(powMineFeeValue())"></span></div>
+ <div class="mine-stat-value">LUUN <span x-text="amountLabel(feeEstimates.mine?.fee ?? 0)"></span></div>
</div>
<div class="mine-stat">
<div class="mine-stat-label">Miner receives</div>
@@ -1706,9 +1872,10 @@ const INDEX_HTML: &str = r#"<!doctype html>
</label>
</div>
<div class="mine-fee-fields">
- <label>Fee<input x-model="powMineFeeDraft" @input="powMineFeeDirty = true" type="number" min="0" step="0.000001"></label>
+ <label>Fee / byte<input x-model="powMineFeeDraft" @input="powMineFeeDirty = true; scheduleFeeEstimates()" type="number" min="0" step="0.000001" required></label>
<button class="primary" type="submit">Save</button>
</div>
+ <div class="fee-preview" x-text="feeEstimateLabel('mine')"></div>
</form>
</div>
</div>
@@ -2089,7 +2256,7 @@ mod tests {
use tower::ServiceExt;
use crate::{
- adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork},
+ adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store},
app::{NodeCore, PeerBook},
domain::{Block, Ledger, MICRO_LUUN, MINE_REWARD, OutPoint, Transaction, Wallet},
};
@@ -2098,7 +2265,8 @@ mod tests {
AUTH_COOKIE_NAME, HttpState, TransferForm, api_auth_login_form, api_auth_setup_form,
api_auth_status, dev_seed_verify_bypass_allowed, hash_password, hex_encode, pbkdf2_sha256,
persist_burn_settings_config, persist_pow_mining_config, require_auth_middleware,
- validate_password, validate_transfer_form, verify_password, wallet_transaction_rows,
+ required_fee_per_byte_burn, required_fee_per_byte_mine, validate_password,
+ validate_transfer_form, verify_password, wallet_transaction_rows,
};
#[test]
@@ -2316,7 +2484,8 @@ mod tests {
async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState {
config_store::save(&config_path, &config).unwrap();
- let wallet = Wallet::from_seed("auth-test-wallet");
+ let wallet_path = config_path.with_file_name("wallet.json");
+ let (wallet, _) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap();
let ledger = Ledger::new(BTreeMap::new(), 1);
let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0)));
let peers = Arc::new(Mutex::new(PeerBook::default()));
@@ -2329,7 +2498,7 @@ mod tests {
config_store::load_or_create(&config_path).unwrap(),
)),
config_path,
- wallet_path: std::path::PathBuf::from("wallet.json"),
+ wallet_path,
auth_sessions: Arc::new(Mutex::new(BTreeMap::new())),
}
}
@@ -2452,7 +2621,7 @@ mod tests {
let error = validate_transfer_form(TransferForm {
to: " ".to_string(),
amount: 1,
- fee: Some(1),
+ fee_per_byte: Some(1),
utxos: String::new(),
})
.unwrap_err();
@@ -2461,7 +2630,7 @@ mod tests {
let error = validate_transfer_form(TransferForm {
to: "abc".to_string(),
amount: 0,
- fee: Some(1),
+ fee_per_byte: Some(1),
utxos: String::new(),
})
.unwrap_err();
@@ -2474,11 +2643,29 @@ mod tests {
let error = validate_transfer_form(TransferForm {
to: "abc".to_string(),
amount: 1,
- fee: None,
+ fee_per_byte: None,
utxos: String::new(),
})
.unwrap_err();
- assert!(error.to_string().contains("fee is required"));
+ assert!(error.to_string().contains("fee per byte is required"));
+ }
+
+ #[test]
+ fn burn_and_mine_forms_require_fee_per_byte() {
+ let burn = required_fee_per_byte_burn(&super::BurnSettingsForm {
+ enabled: Some(true),
+ amount: 1,
+ fee_per_byte: None,
+ })
+ .unwrap_err();
+ assert!(burn.to_string().contains("fee per byte is required"));
+
+ let mine = required_fee_per_byte_mine(&super::PowMiningForm {
+ enabled: true,
+ fee_per_byte: None,
+ })
+ .unwrap_err();
+ assert!(mine.to_string().contains("fee per byte is required"));
}
#[test]
@@ -2486,7 +2673,7 @@ mod tests {
let (to, amount, fee, utxos) = validate_transfer_form(TransferForm {
to: " abc ".to_string(),
amount: 2,
- fee: Some(3),
+ fee_per_byte: Some(3),
utxos: String::new(),
})
.unwrap();
@@ -2502,7 +2689,7 @@ mod tests {
let (_, _, _, utxos) = validate_transfer_form(TransferForm {
to: "abc".to_string(),
amount: 2,
- fee: Some(3),
+ fee_per_byte: Some(3),
utxos: "tx-one:0\ntx:with:colons:7,\n".to_string(),
})
.unwrap();
diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs
@@ -5,11 +5,21 @@ use std::{
};
use anyhow::{Context, Result, anyhow, bail};
+use chacha20poly1305::{
+ ChaCha20Poly1305, KeyInit, Nonce,
+ aead::{Aead, Payload},
+};
+use pbkdf2::pbkdf2_hmac;
use serde::{Deserialize, Serialize};
+use sha2::Sha256;
use crate::domain::Wallet;
-const WALLET_FILE_VERSION: u32 = 2;
+const WALLET_FILE_VERSION: u32 = 3;
+const PLAINTEXT_WALLET_FILE_VERSION: u32 = 2;
+const WALLET_ENCRYPTION_ALGORITHM: &str = "chacha20poly1305";
+const WALLET_ENCRYPTION_KDF: &str = "pbkdf2-sha256";
+const WALLET_ENCRYPTION_ITERATIONS: u32 = 210_000;
const GENERATED_SEED_WORDS: usize = 24;
const SEED_WORDS: &[&str] = &[
"able", "acid", "acorn", "adapt", "agent", "anchor", "angle", "apple", "asset", "atlas",
@@ -42,8 +52,27 @@ const SEED_WORDS: &[&str] = &[
#[derive(Debug, Serialize, Deserialize)]
struct WalletFile {
version: u32,
- seed: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ seed: Option<String>,
address: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ encryption: Option<EncryptedWalletSeed>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WalletMetadata {
+ pub address: String,
+ pub encrypted: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct EncryptedWalletSeed {
+ algorithm: String,
+ kdf: String,
+ kdf_iterations: u32,
+ salt: String,
+ nonce: String,
+ ciphertext: String,
}
pub fn load_or_create(path: &Path) -> Result<Wallet> {
@@ -66,42 +95,116 @@ pub fn replace_with_generated_seed_phrase(path: &Path) -> Result<(Wallet, String
Ok((wallet, seed))
}
+pub fn replace_with_generated_seed_phrase_encrypted(
+ path: &Path,
+ password: &str,
+) -> Result<(Wallet, String)> {
+ let seed = generate_seed_phrase()?;
+ let wallet = write_wallet_encrypted(path, seed.clone(), password, WalletFileMode::Replace)?;
+ Ok((wallet, seed))
+}
+
pub fn replace_with_imported_seed_phrase(path: &Path, seed_phrase: &str) -> Result<Wallet> {
let seed = normalize_seed_phrase(seed_phrase)?;
write_wallet(path, seed, WalletFileMode::Replace)
}
+pub fn replace_with_imported_seed_phrase_encrypted(
+ path: &Path,
+ seed_phrase: &str,
+ password: &str,
+) -> Result<Wallet> {
+ let seed = normalize_seed_phrase(seed_phrase)?;
+ write_wallet_encrypted(path, seed, password, WalletFileMode::Replace)
+}
+
pub fn setup_seed_phrase(path: &Path) -> Result<Option<String>> {
+ setup_seed_phrase_with_password(path, None)
+}
+
+pub fn setup_seed_phrase_with_password(
+ path: &Path,
+ password: Option<&str>,
+) -> Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
let stored = read_wallet_file(path)?;
- let normalized = match normalize_seed_phrase(&stored.seed) {
+ let seed = match wallet_seed(&stored, password) {
+ Ok(seed) => seed,
+ Err(_) => return Ok(None),
+ };
+ let normalized = match normalize_seed_phrase(&seed) {
Ok(seed) => seed,
Err(_) => return Ok(None),
};
- if normalized == stored.seed {
+ if normalized == seed {
Ok(Some(normalized))
} else {
Ok(None)
}
}
-fn load(path: &Path) -> Result<Wallet> {
+pub fn metadata(path: &Path) -> Result<Option<WalletMetadata>> {
+ if !path.exists() {
+ return Ok(None);
+ }
+ let stored = read_wallet_file(path)?;
+ Ok(Some(WalletMetadata {
+ address: stored.address,
+ encrypted: stored.encryption.is_some(),
+ }))
+}
+
+pub fn load_with_password(path: &Path, password: &str) -> Result<Wallet> {
+ load_encrypted_or_plaintext(path, Some(password))
+}
+
+pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> {
+ if !path.exists() {
+ return Ok(());
+ }
let stored = read_wallet_file(path)?;
+ if stored.encryption.is_some() {
+ let _ = wallet_from_stored(&stored, Some(password))?;
+ return Ok(());
+ }
+ let seed = stored.seed.context("wallet file does not contain a seed")?;
+ let seed = normalize_seed_phrase(&seed).unwrap_or(seed);
+ let wallet = Wallet::from_seed(&seed);
+ if wallet.address() != stored.address {
+ bail!(
+ "wallet file has address {}, but its seed derives {}",
+ stored.address,
+ wallet.address()
+ );
+ }
+ let mut file = open_wallet_file(path, WalletFileMode::Replace)?;
+ write_encrypted_wallet_file(&mut file, seed, wallet.address(), password)
+ .with_context(|| format!("failed to encrypt wallet file {}", path.display()))
+}
- let wallet = Wallet::from_seed(&stored.seed);
+fn load(path: &Path) -> Result<Wallet> {
+ load_encrypted_or_plaintext(path, None)
+}
+
+fn load_encrypted_or_plaintext(path: &Path, password: Option<&str>) -> Result<Wallet> {
+ let stored = read_wallet_file(path)?;
+ let wallet = wallet_from_stored(&stored, password)?;
if stored.version == 1 {
let mut file = OpenOptions::new()
.write(true)
.truncate(true)
.open(path)
.with_context(|| format!("failed to migrate wallet file {}", path.display()))?;
- write_wallet_file(&mut file, stored.seed, wallet.address())
+ let seed = stored
+ .seed
+ .context("legacy wallet file does not contain a seed")?;
+ write_wallet_file(&mut file, seed, wallet.address())
.with_context(|| format!("failed to migrate wallet file {}", path.display()))?;
return Ok(wallet);
}
- if stored.version != WALLET_FILE_VERSION {
+ if stored.version != WALLET_FILE_VERSION && stored.version != PLAINTEXT_WALLET_FILE_VERSION {
bail!(
"unsupported wallet file version {} in {}",
stored.version,
@@ -120,6 +223,22 @@ fn load(path: &Path) -> Result<Wallet> {
Ok(wallet)
}
+fn wallet_from_stored(stored: &WalletFile, password: Option<&str>) -> Result<Wallet> {
+ let seed = wallet_seed(stored, password)?;
+ Ok(Wallet::from_seed(&seed))
+}
+
+fn wallet_seed(stored: &WalletFile, password: Option<&str>) -> Result<String> {
+ if let Some(encryption) = &stored.encryption {
+ let password = password.context("wallet is encrypted; unlock it with the UI password")?;
+ return decrypt_seed(encryption, &stored.address, password);
+ }
+ stored
+ .seed
+ .clone()
+ .context("wallet file does not contain a seed")
+}
+
fn read_wallet_file(path: &Path) -> Result<WalletFile> {
let bytes =
fs::read(path).with_context(|| format!("failed to read wallet file {}", path.display()))?;
@@ -140,11 +259,44 @@ fn write_wallet(path: &Path, seed: String, mode: WalletFileMode) -> Result<Walle
Ok(wallet)
}
+fn write_wallet_encrypted(
+ path: &Path,
+ seed: String,
+ password: &str,
+ mode: WalletFileMode,
+) -> Result<Wallet> {
+ let wallet = Wallet::from_seed(&seed);
+ let mut file = open_wallet_file(path, mode)?;
+ write_encrypted_wallet_file(&mut file, seed, wallet.address(), password)
+ .with_context(|| format!("failed to write wallet file {}", path.display()))?;
+ Ok(wallet)
+}
+
fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> {
let stored = WalletFile {
+ version: PLAINTEXT_WALLET_FILE_VERSION,
+ seed: Some(seed),
+ address: address.to_string(),
+ encryption: None,
+ };
+ let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
+ file.write_all(&bytes)?;
+ file.write_all(b"\n")?;
+ Ok(())
+}
+
+fn write_encrypted_wallet_file(
+ file: &mut File,
+ seed: String,
+ address: &str,
+ password: &str,
+) -> Result<()> {
+ let encryption = encrypt_seed(&seed, address, password)?;
+ let stored = WalletFile {
version: WALLET_FILE_VERSION,
- seed,
+ seed: None,
address: address.to_string(),
+ encryption: Some(encryption),
};
let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
file.write_all(&bytes)?;
@@ -152,6 +304,70 @@ fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()>
Ok(())
}
+fn encrypt_seed(seed: &str, address: &str, password: &str) -> Result<EncryptedWalletSeed> {
+ let salt = random_bytes::<16>()?;
+ let nonce = random_bytes::<12>()?;
+ let key = wallet_encryption_key(password, &salt, WALLET_ENCRYPTION_ITERATIONS);
+ let cipher = ChaCha20Poly1305::new((&key).into());
+ let ciphertext = cipher
+ .encrypt(
+ Nonce::from_slice(&nonce),
+ Payload {
+ msg: seed.as_bytes(),
+ aad: address.as_bytes(),
+ },
+ )
+ .map_err(|_| anyhow!("failed to encrypt wallet seed"))?;
+ Ok(EncryptedWalletSeed {
+ algorithm: WALLET_ENCRYPTION_ALGORITHM.to_string(),
+ kdf: WALLET_ENCRYPTION_KDF.to_string(),
+ kdf_iterations: WALLET_ENCRYPTION_ITERATIONS,
+ salt: hex_encode(salt),
+ nonce: hex_encode(nonce),
+ ciphertext: hex_encode(ciphertext),
+ })
+}
+
+fn decrypt_seed(encryption: &EncryptedWalletSeed, address: &str, password: &str) -> Result<String> {
+ if encryption.algorithm != WALLET_ENCRYPTION_ALGORITHM {
+ bail!("unsupported wallet encryption algorithm");
+ }
+ if encryption.kdf != WALLET_ENCRYPTION_KDF {
+ bail!("unsupported wallet encryption kdf");
+ }
+ let salt = decode_hex(&encryption.salt).context("invalid wallet encryption salt")?;
+ let nonce = decode_hex(&encryption.nonce).context("invalid wallet encryption nonce")?;
+ let ciphertext = decode_hex(&encryption.ciphertext).context("invalid wallet encrypted seed")?;
+ if nonce.len() != 12 {
+ bail!("invalid wallet encryption nonce length");
+ }
+ let key = wallet_encryption_key(password, &salt, encryption.kdf_iterations);
+ let cipher = ChaCha20Poly1305::new((&key).into());
+ let plaintext = cipher
+ .decrypt(
+ Nonce::from_slice(&nonce),
+ Payload {
+ msg: &ciphertext,
+ aad: address.as_bytes(),
+ },
+ )
+ .map_err(|_| anyhow!("invalid wallet password"))?;
+ String::from_utf8(plaintext).context("wallet seed is not valid utf-8")
+}
+
+fn wallet_encryption_key(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
+ let mut key = [0_u8; 32];
+ pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut key);
+ key
+}
+
+fn random_bytes<const N: usize>() -> Result<[u8; N]> {
+ let mut bytes = [0_u8; N];
+ getrandom::getrandom(&mut bytes)
+ .map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?;
+ Ok(bytes)
+}
+
fn generate_seed_phrase() -> Result<String> {
let mut bytes = [0_u8; GENERATED_SEED_WORDS];
getrandom::getrandom(&mut bytes)
@@ -163,6 +379,38 @@ fn generate_seed_phrase() -> Result<String> {
Ok(words.join(" "))
}
+fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut encoded = String::with_capacity(bytes.as_ref().len() * 2);
+ for byte in bytes.as_ref() {
+ encoded.push(HEX[(byte >> 4) as usize] as char);
+ encoded.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ encoded
+}
+
+fn decode_hex(input: &str) -> Result<Vec<u8>> {
+ if input.len() % 2 != 0 {
+ bail!("hex string has odd length");
+ }
+ let mut bytes = Vec::with_capacity(input.len() / 2);
+ for pair in input.as_bytes().chunks_exact(2) {
+ let high = decode_hex_nibble(pair[0])?;
+ let low = decode_hex_nibble(pair[1])?;
+ bytes.push((high << 4) | low);
+ }
+ Ok(bytes)
+}
+
+fn decode_hex_nibble(byte: u8) -> Result<u8> {
+ match byte {
+ b'0'..=b'9' => Ok(byte - b'0'),
+ b'a'..=b'f' => Ok(byte - b'a' + 10),
+ b'A'..=b'F' => Ok(byte - b'A' + 10),
+ _ => bail!("invalid hex character"),
+ }
+}
+
fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> {
let words = seed_phrase
.split_whitespace()
@@ -219,8 +467,9 @@ mod tests {
use tempfile::tempdir;
use super::{
- load_or_create, replace_with_generated_seed_phrase, replace_with_imported_seed_phrase,
- setup_seed_phrase,
+ encrypt_existing_with_password, load_or_create, load_with_password, metadata,
+ replace_with_generated_seed_phrase, replace_with_generated_seed_phrase_encrypted,
+ replace_with_imported_seed_phrase, setup_seed_phrase, setup_seed_phrase_with_password,
};
#[test]
@@ -290,6 +539,59 @@ mod tests {
}
#[test]
+ fn encrypted_generated_wallet_hides_seed_and_requires_password() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+
+ let (wallet, seed_phrase) =
+ replace_with_generated_seed_phrase_encrypted(&path, "correct horse battery staple")
+ .unwrap();
+ let stored = fs::read_to_string(&path).unwrap();
+
+ assert!(stored.contains("\"version\": 3"));
+ assert!(stored.contains("\"encryption\""));
+ assert!(!stored.contains(&seed_phrase));
+ assert_eq!(metadata(&path).unwrap().unwrap().address, wallet.address());
+ assert!(metadata(&path).unwrap().unwrap().encrypted);
+ assert!(
+ load_or_create(&path)
+ .unwrap_err()
+ .to_string()
+ .contains("encrypted")
+ );
+ assert!(load_with_password(&path, "wrong password").is_err());
+
+ let loaded = load_with_password(&path, "correct horse battery staple").unwrap();
+ assert_eq!(loaded.address(), wallet.address());
+ assert_eq!(
+ setup_seed_phrase_with_password(&path, Some("correct horse battery staple"))
+ .unwrap()
+ .as_deref(),
+ Some(seed_phrase.as_str())
+ );
+ assert!(setup_seed_phrase(&path).unwrap().is_none());
+ }
+
+ #[test]
+ fn plaintext_wallet_can_be_encrypted_in_place() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+
+ let (wallet, seed_phrase) = replace_with_generated_seed_phrase(&path).unwrap();
+ encrypt_existing_with_password(&path, "correct horse battery staple").unwrap();
+ let stored = fs::read_to_string(&path).unwrap();
+
+ assert!(stored.contains("\"version\": 3"));
+ assert!(!stored.contains(&seed_phrase));
+ assert_eq!(
+ load_with_password(&path, "correct horse battery staple")
+ .unwrap()
+ .address(),
+ wallet.address()
+ );
+ }
+
+ #[test]
fn imports_normalized_seed_phrase() {
let dir = tempdir().unwrap();
let path = dir.path().join("wallet.json");
diff --git a/src/app.rs b/src/app.rs
@@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::domain::{
- Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE, Ledger,
- OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
+ Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE,
+ Ledger, OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
};
pub type SharedNode = Arc<Mutex<NodeCore>>;
@@ -32,6 +32,38 @@ pub struct NodeConfig {
pub burn_fee: Amount,
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct FeeEstimate {
+ pub bytes: usize,
+ pub fee: Amount,
+}
+
+#[derive(Clone, Debug)]
+enum NodeWallet {
+ Unlocked(Wallet),
+ Locked { address: String },
+}
+
+impl NodeWallet {
+ fn address(&self) -> &str {
+ match self {
+ Self::Unlocked(wallet) => wallet.address(),
+ Self::Locked { address } => address,
+ }
+ }
+
+ fn unlocked(&self) -> Result<&Wallet> {
+ match self {
+ Self::Unlocked(wallet) => Ok(wallet),
+ Self::Locked { .. } => bail!("wallet is locked"),
+ }
+ }
+
+ fn is_locked(&self) -> bool {
+ matches!(self, Self::Locked { .. })
+ }
+}
+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GossipEnvelope {
@@ -102,6 +134,7 @@ pub struct TransactionRejection {
pub struct NodeStatus {
pub wallet_address: String,
pub wallet_balance: Amount,
+ pub wallet_locked: bool,
pub launch_profile: LaunchProfileStatus,
pub mining: MiningStatus,
pub chain: ChainStatus,
@@ -148,7 +181,7 @@ pub struct AutoMinePlan {
#[derive(Clone, Debug)]
pub struct NodeCore {
- wallet: Wallet,
+ wallet: NodeWallet,
ledger: Ledger,
automatic_mining_enabled: bool,
pow_mining_enabled: bool,
@@ -172,7 +205,25 @@ impl NodeCore {
}
pub fn from_ledger(wallet: Wallet, ledger: Ledger, burn_per_block: Amount) -> Self {
- Self::from_ledger_with_burn_fee(wallet, ledger, burn_per_block, DEFAULT_TRANSACTION_FEE)
+ Self::from_ledger_with_burn_fee(wallet, ledger, burn_per_block, DEFAULT_FEE_PER_BYTE)
+ }
+
+ pub fn from_locked_wallet_address(
+ address: impl Into<String>,
+ ledger: Ledger,
+ automatic_mining_enabled: bool,
+ burn_per_block: Amount,
+ burn_fee: Amount,
+ ) -> Self {
+ Self::from_node_wallet_with_burn_fee_and_enabled(
+ NodeWallet::Locked {
+ address: address.into(),
+ },
+ ledger,
+ automatic_mining_enabled,
+ burn_per_block,
+ burn_fee,
+ )
}
pub fn from_ledger_with_burn_fee(
@@ -197,12 +248,28 @@ impl NodeCore {
burn_per_block: Amount,
burn_fee: Amount,
) -> Self {
+ Self::from_node_wallet_with_burn_fee_and_enabled(
+ NodeWallet::Unlocked(wallet),
+ ledger,
+ automatic_mining_enabled,
+ burn_per_block,
+ burn_fee,
+ )
+ }
+
+ fn from_node_wallet_with_burn_fee_and_enabled(
+ wallet: NodeWallet,
+ ledger: Ledger,
+ automatic_mining_enabled: bool,
+ burn_per_block: Amount,
+ burn_fee: Amount,
+ ) -> Self {
Self {
wallet,
ledger,
automatic_mining_enabled,
pow_mining_enabled: false,
- pow_mine_fee: DEFAULT_MINE_FEE,
+ pow_mine_fee: DEFAULT_FEE_PER_BYTE,
burn_per_block,
burn_fee,
last_auto_burn_height: None,
@@ -215,8 +282,12 @@ impl NodeCore {
self.wallet.address()
}
+ pub fn wallet_is_locked(&self) -> bool {
+ self.wallet.is_locked()
+ }
+
pub fn replace_wallet(&mut self, wallet: Wallet) {
- self.wallet = wallet;
+ self.wallet = NodeWallet::Unlocked(wallet);
self.last_auto_burn_height = None;
self.last_auto_pow_mine_anchor = None;
}
@@ -355,6 +426,7 @@ impl NodeCore {
NodeStatus {
wallet_address: self.wallet.address().to_string(),
wallet_balance: self.ledger.balance_of(self.wallet.address()),
+ wallet_locked: self.wallet.is_locked(),
launch_profile: LaunchProfileStatus {
profile_id: launch_profile.profile_id.clone(),
profile_hash: chain.launch_profile_hash.clone(),
@@ -414,9 +486,6 @@ impl NodeCore {
}
pub fn set_pow_mining_settings(&mut self, enabled: bool, fee: Amount) -> Result<()> {
- if fee > self.ledger.status().mine_reward {
- bail!("mine fee cannot exceed mine reward");
- }
self.pow_mining_enabled = enabled;
self.pow_mine_fee = fee;
if !enabled {
@@ -430,13 +499,32 @@ impl NodeCore {
}
pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> {
- let tx = self.ledger.build_burn(&self.wallet, amount, fee)?;
+ let tx = self
+ .ledger
+ .build_burn(self.wallet.unlocked()?, amount, fee)?;
if self.ledger.submit_transaction(tx.clone())? {
self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
}
Ok(tx)
}
+ pub fn burn_with_fee_rate(
+ &mut self,
+ amount: Amount,
+ fee_per_byte: Amount,
+ ) -> Result<(Transaction, FeeEstimate)> {
+ let (tx, estimate) = self.build_burn_with_fee_rate(amount, fee_per_byte)?;
+ if self.ledger.submit_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
+ }
+ Ok((tx, estimate))
+ }
+
+ pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> {
+ self.build_burn_with_fee_rate(amount, fee_per_byte)
+ .map(|(_, estimate)| estimate)
+ }
+
pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> {
self.transfer_with_fee(to, amount, DEFAULT_TRANSACTION_FEE)
}
@@ -447,7 +535,9 @@ impl NodeCore {
amount: Amount,
fee: Amount,
) -> Result<Transaction> {
- let tx = self.ledger.build_transfer(&self.wallet, to, amount, fee)?;
+ let tx = self
+ .ledger
+ .build_transfer(self.wallet.unlocked()?, to, amount, fee)?;
if self.ledger.submit_transaction(tx.clone())? {
self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
}
@@ -461,25 +551,58 @@ impl NodeCore {
fee: Amount,
outpoints: &[OutPoint],
) -> Result<Transaction> {
- let tx =
- self.ledger
- .build_transfer_with_inputs(&self.wallet, to, amount, fee, outpoints)?;
+ let tx = self.ledger.build_transfer_with_inputs(
+ self.wallet.unlocked()?,
+ to,
+ amount,
+ fee,
+ outpoints,
+ )?;
if self.ledger.submit_transaction(tx.clone())? {
self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
}
Ok(tx)
}
+ pub fn transfer_with_fee_rate(
+ &mut self,
+ to: impl Into<String>,
+ amount: Amount,
+ fee_per_byte: Amount,
+ outpoints: &[OutPoint],
+ ) -> Result<(Transaction, FeeEstimate)> {
+ let (tx, estimate) =
+ self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?;
+ if self.ledger.submit_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
+ }
+ Ok((tx, estimate))
+ }
+
+ pub fn estimate_transfer_fee(
+ &self,
+ to: impl Into<String>,
+ amount: Amount,
+ fee_per_byte: Amount,
+ outpoints: &[OutPoint],
+ ) -> Result<FeeEstimate> {
+ self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)
+ .map(|(_, estimate)| estimate)
+ }
+
pub fn mine_pow_reward(&mut self) -> Result<Transaction> {
- let tx = self
- .ledger
- .build_mine_with_fee(self.wallet.address(), self.pow_mine_fee)?;
+ let (tx, _) = self.build_mine_with_fee_rate(self.pow_mine_fee)?;
if self.ledger.submit_transaction(tx.clone())? {
self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
}
Ok(tx)
}
+ pub fn estimate_mine_fee(&self, fee_per_byte: Amount) -> Result<FeeEstimate> {
+ self.build_mine_with_fee_rate(fee_per_byte)
+ .map(|(_, estimate)| estimate)
+ }
+
pub fn receive_transaction(&mut self, tx: Transaction) -> Result<bool> {
let accepted = self.ledger.submit_transaction(tx.clone())?;
if accepted {
@@ -488,6 +611,46 @@ impl NodeCore {
Ok(accepted)
}
+ fn build_burn_with_fee_rate(
+ &self,
+ amount: Amount,
+ fee_per_byte: Amount,
+ ) -> Result<(Transaction, FeeEstimate)> {
+ converge_fee_by_byte(fee_per_byte, |fee| {
+ self.ledger.build_burn(self.wallet.unlocked()?, amount, fee)
+ })
+ }
+
+ fn build_transfer_with_fee_rate(
+ &self,
+ to: impl Into<String>,
+ amount: Amount,
+ fee_per_byte: Amount,
+ outpoints: &[OutPoint],
+ ) -> Result<(Transaction, FeeEstimate)> {
+ let to = to.into();
+ converge_fee_by_byte(fee_per_byte, |fee| {
+ if outpoints.is_empty() {
+ self.ledger
+ .build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee)
+ } else {
+ self.ledger.build_transfer_with_inputs(
+ self.wallet.unlocked()?,
+ to.clone(),
+ amount,
+ fee,
+ outpoints,
+ )
+ }
+ })
+ }
+
+ fn build_mine_with_fee_rate(&self, fee_per_byte: Amount) -> Result<(Transaction, FeeEstimate)> {
+ converge_fee_by_byte(fee_per_byte, |fee| {
+ self.ledger.build_mine_with_fee(self.wallet.address(), fee)
+ })
+ }
+
pub fn mine_one(&mut self) -> Result<Block> {
self.mine_one_at(now_ms())
}
@@ -526,6 +689,15 @@ impl NodeCore {
skipped_reason: None,
};
+ if self.wallet.is_locked() {
+ return AutoMinePlan {
+ pow_mined: None,
+ burned: None,
+ work: None,
+ skipped_reason: Some("wallet is locked".to_string()),
+ };
+ }
+
let pow_error = match self.prepare_automatic_pow_mine() {
Ok(tx) => {
plan.pow_mined = tx;
@@ -594,9 +766,7 @@ impl NodeCore {
{
return Ok(None);
}
- let tx = self
- .ledger
- .build_mine_with_fee(self.wallet.address(), self.pow_mine_fee)?;
+ let (tx, _) = self.build_mine_with_fee_rate(self.pow_mine_fee)?;
if self.ledger.submit_transaction(tx.clone())? {
self.last_auto_pow_mine_anchor = Some(anchor);
self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
@@ -631,24 +801,48 @@ impl NodeCore {
return Ok(None);
}
- let fee = self.burn_fee;
+ let fee_per_byte = self.burn_fee;
let balance = self.ledger.balance_of(self.wallet.address());
- let amount = if balance >= self.burn_per_block.saturating_add(fee) {
- self.burn_per_block
- } else {
- balance.saturating_sub(fee)
- };
- if amount == 0 {
+ let mut low = 1;
+ let mut high = self.burn_per_block.min(balance);
+ let mut best = None;
+ while low <= high {
+ let amount = low + (high - low) / 2;
+ match self.build_burn_with_fee_rate(amount, fee_per_byte) {
+ Ok((tx, estimate)) => {
+ let fits = amount
+ .checked_add(estimate.fee)
+ .is_some_and(|required| required <= balance);
+ if fits {
+ best = Some(tx);
+ if amount == Amount::MAX {
+ break;
+ }
+ low = amount + 1;
+ } else {
+ high = amount.saturating_sub(1);
+ }
+ }
+ Err(_) => {
+ high = amount.saturating_sub(1);
+ }
+ }
+ }
+ let Some(tx) = best else {
self.last_auto_burn_height = Some(current_height);
return Ok(None);
+ };
+ if self.ledger.submit_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
}
- let tx = self.burn_with_fee(amount, fee)?;
self.last_auto_burn_height = Some(current_height);
Ok(Some(tx))
}
pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> {
- let block = self.ledger.mine_next_block(&self.wallet, timestamp_ms)?;
+ let block = self
+ .ledger
+ .mine_next_block(self.wallet.unlocked()?, timestamp_ms)?;
self.ledger.apply_locally_mined_block(block.clone())?;
self.outbox.push(GossipEnvelope::Block(block.clone()));
Ok(block)
@@ -659,7 +853,7 @@ impl NodeCore {
work: PreparedBlock,
vdf_output: String,
) -> Result<Block> {
- let block = work.finish(&self.wallet, vdf_output);
+ let block = work.finish(self.wallet.unlocked()?, vdf_output);
self.ledger.apply_locally_mined_block(block.clone())?;
self.outbox.push(GossipEnvelope::Block(block.clone()));
Ok(block)
@@ -886,11 +1080,69 @@ pub fn now_ms() -> u64 {
.as_millis() as u64
}
+fn converge_fee_by_byte(
+ fee_per_byte: Amount,
+ mut build: impl FnMut(Amount) -> Result<Transaction>,
+) -> Result<(Transaction, FeeEstimate)> {
+ let mut fee = 0;
+ let mut best = None;
+ for _ in 0..64 {
+ let tx = build(fee)?;
+ let bytes = tx.serialized_size_bytes()?;
+ let required_fee = fee_per_byte
+ .checked_mul(bytes as Amount)
+ .context("fee per byte times transaction bytes overflows")?;
+ if fee == required_fee {
+ return Ok((tx, FeeEstimate { bytes, fee }));
+ }
+ if fee > required_fee
+ && best
+ .as_ref()
+ .is_none_or(|(_, estimate): &(Transaction, FeeEstimate)| fee < estimate.fee)
+ {
+ best = Some((tx, FeeEstimate { bytes, fee }));
+ }
+ fee = required_fee;
+ }
+
+ let tx = build(fee)?;
+ let bytes = tx.serialized_size_bytes()?;
+ let required_fee = fee_per_byte
+ .checked_mul(bytes as Amount)
+ .context("fee per byte times transaction bytes overflows")?;
+ if fee >= required_fee {
+ if best
+ .as_ref()
+ .is_none_or(|(_, estimate): &(Transaction, FeeEstimate)| fee < estimate.fee)
+ {
+ best = Some((tx, FeeEstimate { bytes, fee }));
+ }
+ if let Some(best) = best {
+ return Ok(best);
+ }
+ }
+ let tx = build(required_fee)?;
+ let bytes = tx.serialized_size_bytes()?;
+ let final_required_fee = fee_per_byte
+ .checked_mul(bytes as Amount)
+ .context("fee per byte times transaction bytes overflows")?;
+ if required_fee < final_required_fee {
+ bail!("fee per byte did not converge");
+ }
+ Ok((
+ tx,
+ FeeEstimate {
+ bytes,
+ fee: required_fee,
+ },
+ ))
+}
+
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
- use crate::domain::{DEFAULT_MINE_FEE, Transaction, Wallet};
+ use crate::domain::{MICRO_LUUN, Transaction, Wallet};
use super::{NodeConfig, NodeCore};
@@ -952,7 +1204,9 @@ mod tests {
};
assert_eq!(anchor, &node.chain().last().unwrap().hash);
assert_eq!(output.address, wallet.address());
- assert_eq!(*fee, DEFAULT_MINE_FEE);
+ let minimum_fee = first_mine.serialized_size_bytes().unwrap() as u64;
+ assert!(*fee >= minimum_fee);
+ assert!(*fee <= minimum_fee + 1);
assert_eq!(
*difficulty_bits,
node.ledger().current_mine_difficulty_bits()
@@ -975,21 +1229,27 @@ mod tests {
burn_fee: 0,
});
- let configured_fee = DEFAULT_MINE_FEE * 2;
- node.set_pow_mining_settings(true, configured_fee).unwrap();
+ let configured_fee_per_byte = 2;
+ node.set_pow_mining_settings(true, configured_fee_per_byte)
+ .unwrap();
let plan = node.prepare_automatic_mining(1);
let mine = plan.pow_mined.expect("PoW should be queued");
- assert_eq!(mine.fee(), configured_fee);
+ let minimum_fee = mine.serialized_size_bytes().unwrap() as u64 * configured_fee_per_byte;
+ assert!(mine.fee() >= minimum_fee);
+ assert!(mine.fee() <= minimum_fee + configured_fee_per_byte);
assert_eq!(
mine.amount(),
- node.ledger().status().mine_reward - configured_fee
+ node.ledger().status().mine_reward - mine.fee()
+ );
+ assert_eq!(
+ node.status().mining.automatic_pow_mine_fee,
+ configured_fee_per_byte
);
- assert_eq!(node.status().mining.automatic_pow_mine_fee, configured_fee);
}
#[test]
- fn automatic_pow_mining_rejects_fee_above_reward() {
+ fn automatic_pow_mining_reports_fee_rate_above_reward() {
let wallet = Wallet::from_seed("automatic-pow-mining-too-high-fee-wallet");
let mut node = NodeCore::new(NodeConfig {
wallet,
@@ -999,12 +1259,37 @@ mod tests {
burn_fee: 0,
});
- let error = node
- .set_pow_mining_settings(true, node.ledger().status().mine_reward + 1)
- .unwrap_err();
+ node.set_pow_mining_settings(true, MICRO_LUUN).unwrap();
+ let plan = node.prepare_automatic_mining(1);
- assert!(format!("{error:#}").contains("mine fee cannot exceed mine reward"));
- assert!(!node.status().mining.pow_mining_enabled);
+ assert!(plan.pow_mined.is_none());
+ assert!(
+ plan.skipped_reason
+ .as_deref()
+ .unwrap_or_default()
+ .contains("fee exceeds reward")
+ );
+ assert!(node.status().mining.pow_mining_enabled);
+ }
+
+ #[test]
+ fn fee_rate_transfer_and_burn_pay_at_least_bytes_times_rate() {
+ 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);
+ 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, &[])
+ .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 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
@@ -11,6 +11,7 @@ pub const BLOCK_REWARD: Amount = 100 * MICRO_LUUN;
pub const MINE_REWARD: Amount = MICRO_LUUN;
pub const DEFAULT_MINE_FEE: Amount = MINE_REWARD / 100;
pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_LUUN;
+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;
pub const MINE_DIFFICULTY_BITS: u32 = 12;
@@ -226,6 +227,10 @@ impl Transaction {
format!("{}:{}", self.signing_payload(), self.signature())
}
+ pub fn serialized_size_bytes(&self) -> Result<usize> {
+ serialized_transaction_size_bytes(self)
+ }
+
fn signing_payload(&self) -> String {
match self {
Self::Transfer {
diff --git a/src/main.rs b/src/main.rs
@@ -39,8 +39,9 @@ async fn main() -> Result<()> {
chain_store.path().display()
);
}
- let wallet = wallet_store::load_or_create(&wallet_path)?;
let mut ui_config = config_store::load_or_create(&config_path)?;
+ let wallet_load = load_startup_wallet(&wallet_path)?;
+ let wallet_address = wallet_load.address().to_string();
if opts.chain_mode == ChainMode::Genesis {
ui_config.setup_complete = false;
ui_config.mining_enabled = true;
@@ -49,18 +50,27 @@ async fn main() -> Result<()> {
ui_config.burn_fee = GENESIS_INITIAL_BURN_FEE;
config_store::save(&config_path, &ui_config)?;
}
- let ledger = initialize_ledger(&opts, wallet.address(), &chain_store).await?;
+ let ledger = initialize_ledger(&opts, &wallet_address, &chain_store).await?;
let has_chain = opts.has_chain() || persisted_chain_exists;
let initial_burn_per_block = initial_burn_per_block(&opts, &ui_config);
let initial_burn_fee = initial_burn_fee(&opts, &ui_config);
- let mut node_core = NodeCore::from_ledger_with_burn_fee_and_enabled(
- wallet,
- ledger,
- ui_config.mining_enabled,
- initial_burn_per_block,
- initial_burn_fee,
- );
+ let mut node_core = match wallet_load {
+ StartupWallet::Unlocked(wallet) => NodeCore::from_ledger_with_burn_fee_and_enabled(
+ wallet,
+ ledger,
+ ui_config.mining_enabled,
+ initial_burn_per_block,
+ initial_burn_fee,
+ ),
+ StartupWallet::Locked { address } => NodeCore::from_locked_wallet_address(
+ address,
+ ledger,
+ ui_config.mining_enabled,
+ initial_burn_per_block,
+ initial_burn_fee,
+ ),
+ };
node_core.set_pow_mining_settings(ui_config.pow_mining_enabled, ui_config.pow_mine_fee)?;
let node: SharedNode = Arc::new(Mutex::new(node_core));
let ui_config = Arc::new(Mutex::new(ui_config));
@@ -73,13 +83,16 @@ async fn main() -> Result<()> {
}
println!("luun wallet: {}", node.lock().await.wallet_address());
+ if node.lock().await.wallet_is_locked() {
+ println!("wallet locked: unlock it in the management UI");
+ }
println!("wallet file: {}", wallet_path.display());
println!("config file: {}", config_path.display());
println!("chain database: {}", chain_store.path().display());
println!("management UI: http://{}", opts.http_addr);
println!("p2p listener: {}", opts.p2p_addr);
println!(
- "automatic mining: VDF-driven, burning {} LUUN per block with {} LUUN fee",
+ "automatic mining: VDF-driven, burning {} LUUN per block with {} LUUN per byte fee rate",
format_luun(initial_burn_per_block),
format_luun(initial_burn_fee)
);
@@ -121,6 +134,38 @@ async fn main() -> Result<()> {
.await
}
+enum StartupWallet {
+ Unlocked(luun::domain::Wallet),
+ Locked { address: String },
+}
+
+impl StartupWallet {
+ fn address(&self) -> &str {
+ match self {
+ Self::Unlocked(wallet) => wallet.address(),
+ Self::Locked { address } => address,
+ }
+ }
+}
+
+fn load_startup_wallet(wallet_path: &Path) -> Result<StartupWallet> {
+ match wallet_store::load_or_create(wallet_path) {
+ Ok(wallet) => Ok(StartupWallet::Unlocked(wallet)),
+ Err(error) => {
+ let Some(metadata) = wallet_store::metadata(wallet_path)? else {
+ return Err(error);
+ };
+ if metadata.encrypted {
+ Ok(StartupWallet::Locked {
+ address: metadata.address,
+ })
+ } else {
+ Err(error)
+ }
+ }
+ }
+}
+
fn format_luun(amount: Amount) -> String {
let whole = amount / MICRO_LUUN;
let fractional = amount % MICRO_LUUN;
@@ -560,7 +605,7 @@ mod tests {
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use luun::{
- adapters::{chain_store::SqliteChainStore, config_store::UiConfig},
+ adapters::{chain_store::SqliteChainStore, config_store::UiConfig, wallet_store},
app::{DEFAULT_BURN_PER_BLOCK, NodeCore},
domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_LUUN, Wallet},
};
@@ -570,8 +615,8 @@ mod tests {
use super::{
ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK,
- extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block,
- initialize_ledger, measure_vdf_rounds, persist_chain_snapshot,
+ StartupWallet, extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block,
+ initialize_ledger, load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot,
run_chain_persistence_with_interval, validate_wallet_for_mode,
};
@@ -602,6 +647,22 @@ mod tests {
}
#[test]
+ fn encrypted_startup_wallet_loads_as_locked_metadata() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+ let (wallet, _) =
+ wallet_store::replace_with_generated_seed_phrase_encrypted(&path, "password-123456")
+ .unwrap();
+
+ let startup = load_startup_wallet(&path).unwrap();
+
+ match startup {
+ StartupWallet::Locked { address } => assert_eq!(address, wallet.address()),
+ StartupWallet::Unlocked(_) => panic!("encrypted wallet should start locked"),
+ }
+ }
+
+ #[test]
fn no_args_starts_setup_mode() {
let opts = parse(&[]).unwrap().unwrap();
assert_eq!(opts.chain_mode, ChainMode::Setup);
diff --git a/tests/luun.rs b/tests/luun.rs
@@ -4,8 +4,8 @@ use luun::{
adapters::chain_store::SqliteChainStore,
app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection},
domain::{
- Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_LUUN, MINE_REWARD,
- VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf,
+ 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;
@@ -115,7 +115,7 @@ fn starter_node(wallet: Wallet) -> NodeCore {
ledger,
true,
DEFAULT_BURN_PER_BLOCK,
- MICRO_LUUN,
+ DEFAULT_FEE_PER_BYTE,
)
}
@@ -192,7 +192,7 @@ fn burn_in_block_creates_ticket_after_maturity_delay() {
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);
+ allocations.insert(bob.address().to_string(), MICRO_LUUN);
let mut ledger = Ledger::new(allocations, 10);
submit_burn(&mut ledger, &bob, 80);
@@ -258,7 +258,7 @@ fn forged_transaction_is_rejected() {
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);
+ allocations.insert(bob.address().to_string(), MICRO_LUUN);
let mut ledger = Ledger::new(allocations, 10);
let mut forged = burn_tx(&ledger, &bob, 10);
@@ -593,7 +593,7 @@ fn automatic_mining_burns_configured_amount_once_per_height() {
genesis_allocations: allocations,
vdf_rounds: 10,
burn_per_block: luun(25),
- burn_fee: MICRO_LUUN,
+ burn_fee: DEFAULT_FEE_PER_BYTE,
});
let first = node.automatic_mine_once(1);
@@ -604,8 +604,12 @@ fn automatic_mining_burns_configured_amount_once_per_height() {
let second = node.automatic_mine_once(2);
assert!(second.burned.is_some());
- assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(luun(25)));
- assert_eq!(second.burned.as_ref().map(|tx| tx.fee()), Some(MICRO_LUUN));
+ 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]
@@ -668,12 +672,15 @@ fn automatic_burn_status_shows_configured_fee() {
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(), 1_000);
+ 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(), 3);
+ assert_eq!(
+ burned.fee(),
+ burned.serialized_size_bytes().unwrap() as u64 * 3
+ );
}
#[test]
@@ -686,18 +693,23 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
genesis_allocations: allocations,
vdf_rounds: 10,
burn_per_block: BLOCK_REWARD + luun(50),
- burn_fee: MICRO_LUUN,
+ 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!(
- outcome.burned.as_ref().map(|tx| tx.amount()),
- Some(BLOCK_REWARD - MICRO_LUUN)
+ burned.fee(),
+ burned.serialized_size_bytes().unwrap() as u64 * DEFAULT_FEE_PER_BYTE
);
- assert_eq!(outcome.burned.as_ref().map(|tx| tx.fee()), Some(MICRO_LUUN));
assert!(outcome.block.is_some());
- assert_eq!(node.ledger().balance_of(alice.address()), MICRO_LUUN);
+ assert_eq!(
+ node.ledger().balance_of(alice.address()),
+ burned.fee() + unspent
+ );
}
#[test]
@@ -706,7 +718,7 @@ fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() {
let bob = Wallet::from_seed("bob");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), 1_000);
- allocations.insert(bob.address().to_string(), 100);
+ allocations.insert(bob.address().to_string(), MICRO_LUUN);
let mut ledger = Ledger::new(allocations.clone(), 25);
submit_burn(&mut ledger, &alice, 1);
@@ -736,7 +748,7 @@ fn automatic_mining_waits_when_wallet_is_not_selected_leader() {
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);
+ allocations.insert(bob.address().to_string(), MICRO_LUUN);
let mut ledger = Ledger::new(allocations.clone(), 25);
submit_burn(&mut ledger, &alice, 1);
@@ -824,17 +836,17 @@ fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() {
ledger.clone(),
true,
DEFAULT_BURN_PER_BLOCK,
- MICRO_LUUN,
+ DEFAULT_FEE_PER_BYTE,
);
let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled(
bob.clone(),
ledger,
false,
DEFAULT_BURN_PER_BLOCK,
- MICRO_LUUN,
+ DEFAULT_FEE_PER_BYTE,
);
bob_node
- .set_pow_mining_settings(true, MICRO_LUUN / 100)
+ .set_pow_mining_settings(true, DEFAULT_FEE_PER_BYTE)
.unwrap();
let bob_plan = bob_node.prepare_automatic_mining(1);
@@ -1690,7 +1702,7 @@ fn friend_node_can_join_snapshot_from_started_chain() {
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, MICRO_LUUN)
+ .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);