Compare commits

..
Author SHA1 Message Date
archipelagoandClaude Opus 5 2c984fbd49 fix(ui): the IBD-finished toast no longer tells a node without LND to fund its wallet
Demo images / Build & push demo images (push) Failing after 52s
When Bitcoin's IBD completed mid-Lightning-goal, the watcher toasted
"you can now fund your wallet" — but the on-chain wallet lives in LND,
not Bitcoin Core. The watcher only checked that the goal had pending
manual steps, never that the install-LND step had completed, so a user
whose LND wasn't installed yet was pointed at a flow that could not
work: the fund modal's address comes from lnd.newaddress and does not
exist until LND is installed (issue #143).

The toast now checks LND's install state at fire time. With LND
installed the message is unchanged; without it, the toast says the
actual next step — install Lightning (LND) — and the Finish setup
button lands on the goal wizard, whose active step is the pending
install-LND one (the wizard itself was already correctly sequenced).

The watcher had no tests; added four pinning its contract: the two
message branches, silence with no in-progress goal, and silence when
the chain was already synced at page load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:24:01 -04:00
archipelagoandClaude Opus 5 a9a30406df fix(disk): count reserved blocks as used, not free
Disk usage was computed as used/size, where size is the raw device size.
ext4 reserves 5% of the filesystem for root — 92.4 GiB of this node's
1.8 TiB — which size includes but nothing can allocate. Two consequences,
both live on archi-dev-box today:

The dashboard advertised 251 GiB free when only 159 GiB could actually be
written, and reported 86.2% usage against df's 90.8%.

Worse, disk_monitor triggers automatic cleanup (podman image prune) at
90%. The disk has been genuinely above that threshold while this returned
86.2%, so the cleanup never once fired — which is exactly how ~72 GB of
dangling images accumulated unnoticed, and why deleting apps appeared to
free nothing.

Both call sites now ask df for avail and use used/(used+avail): the same
figure df itself prints, and the space an operator can actually spend.
Callers deriving free as total - used now get avail.

Note this shifts disk_total_bytes in the analytics series down by the
reserve; historical samples are not comparable across this change.

Tests updated for the three-column output, plus a regression test built
from this box's real numbers asserting the corrected math crosses the 90%
threshold the old math missed. 15/15 disk_monitor tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:01:18 -04:00
8 changed files with 236 additions and 234 deletions
-151
View File
@@ -1,151 +0,0 @@
app:
id: cuprate
name: Cuprate
# Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml).
# Cuprate has no stable release yet — this is explicitly work-in-progress
# software (see upstream README). The image tag below pins the exact
# commit built, since "0.1.0-preview" alone is not reproducible.
version: 0.1.0-preview
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: Cuprate/cuprate
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
# no newer tagged release as of this writing. Re-pin to a tagged release
# once upstream cuts one.
image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14
pull_policy: if-not-present
network: archy-net
# The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are
# appended as its argv, matching the project's own systemd unit
# (cuprated.service) invocation exactly.
custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"]
# The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate`
# user at build time and runs as it unconditionally (USER 1000:1000,
# no shell to switch users at runtime) — same pattern as
# apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer,
# apps/barkd. The bind-mounted data dir must be owned by that literal
# uid or cuprated dies on a permission error the first time it writes.
data_uid: "1000:1000"
dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
# month; cuprated's pruning support is not confirmed stable yet (the
# `pruning` crate exists in the workspace but nothing in this config
# surface toggles it), so this sizes for a full unpruned chain plus
# headroom rather than assuming pruning is available.
- storage: 300Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 300Gi
security:
# FROM scratch, no package manager/shell, ownership fixed at build time
# — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it
# can run fully read-only with an empty capability set.
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
# P2P. Cuprate's own default listen address is already 0.0.0.0
# (p2p.clear_net.listen_on), so no config override is needed — only the
# host-side port differs from Monero's canonical 18080 because that
# number is already taken on this fleet by lnd's REST port.
- host: 18183
container: 18080
protocol: tcp
auth: none
auth_rationale: >-
Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.
# Unrestricted RPC (full node control) is deliberately NOT published.
# cuprated has no RPC authentication, and for a published port to reach
# it the service would have to bind 0.0.0.0 inside the container — at
# which point every other app can reach it directly on 18081, since
# ports[].bind only restricts the HOST side and podman bridges route to
# each other (verified live 2026-08-22: a peer container on archy-net
# got an unauthenticated get_info, from a *different* network). That is
# unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser /
# rpcpassword it writes from generated secrets. So unrestricted RPC is
# left at cuprated's own default — container loopback only, reachable by
# nothing — which is also what upstream intends by refusing a non-local
# bind without an explicit i_know_what_im_doing override.
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
# what wallets use when connecting to a "remote node". Disabled by
# cuprated's own default; enabled via files[] below. A dashboard login
# would break wallet clients connecting programmatically, same
# reasoning as electrumx's port. The daemon still uses its canonical
# container port 18089, but Penpot already owns host port 18089, so this
# maps the public host port to the free 18090 instead.
- host: 18090
container: 18089
protocol: tcp
auth: none
auth_rationale: >-
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
volumes:
- type: bind
source: /var/lib/archipelago/cuprate
target: /home/cuprate
options: [rw]
# Settings that need to differ from cuprated's own documented defaults
# (verified against `cuprated --generate-config` and `--dry-run` locally,
# 2026-08-21):
# - target_max_memory: cuprated's own default auto-detects total *host*
# RAM via sysinfo, which inside a memory-limited container would let
# it size caches far past what resources.memory_limit above actually
# grants — same class of problem bitcoin-knots' -dbcache sizing
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
# - rpc.restricted.enable: cuprated ships this off by default; flip on
# so the auth:none host port above actually serves something instead
# of refusing every connection. port stays at its documented default
# (canonical 18089), and advertise stays false — this node is not
# opting in to being listed as a public remote node over the p2p
# network, just reachable if someone points a wallet at it directly.
# - rpc.unrestricted.address + the allow-public flag: cuprated's own
# default (127.0.0.1) looks like the obviously-correct choice for a
# port meant to stay loopback-only, but verified live (2026-08-21)
# that a service bound literally to 127.0.0.1 *inside* the container
# is unreachable through the host's published port — connections
# reset regardless of how long the daemon has been up. Binding
# 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the
# actual restriction is the same pattern apps/bitcoin-knots already
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
# restricts it externally) — not a new risk, the same one already
# reviewed and accepted for Bitcoin's RPC.
files:
- path: /var/lib/archipelago/cuprate/Cuprated.toml
content: |
network = "Mainnet"
target_max_memory = 3000000000
[rpc.restricted]
enable = true
overwrite: false
health_check:
type: tcp
# Restricted RPC — the only RPC surface published now.
endpoint: localhost:18090
interval: 30s
timeout: 5s
retries: 3
start_period: 5m
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Cuprate
repo: https://github.com/Cuprate/cuprate
@@ -365,18 +365,8 @@ impl RpcHandler {
// after uninstall. The reconciler owns a manifest map independent of // after uninstall. The reconciler owns a manifest map independent of
// podman state, so a raw `podman rm` alone is not enough. // podman state, so a raw `podman rm` alone is not enough.
if let Some(orchestrator) = &self.orchestrator { if let Some(orchestrator) = &self.orchestrator {
let mut teardown_errors = Vec::new();
for app_id in orchestrator_uninstall_app_ids(package_id) { for app_id in orchestrator_uninstall_app_ids(package_id) {
if let Err(err) = orchestrator.remove(&app_id, preserve_data).await { let _ = orchestrator.remove(&app_id, preserve_data).await;
teardown_errors.push(format!("{app_id}: {err:#}"));
}
}
if !teardown_errors.is_empty() {
return Err(anyhow::anyhow!(
"Uninstall {} aborted: failed to remove declarative app unit(s): {}",
package_id,
teardown_errors.join("; ")
));
} }
} }
@@ -2192,11 +2182,6 @@ mod tests {
assert!(!is_missing_container_error("Error: OCI runtime error")); assert!(!is_missing_container_error("Error: OCI runtime error"));
} }
#[test]
fn single_app_uninstall_targets_its_declarative_unit() {
assert_eq!(orchestrator_uninstall_app_ids("cuprate"), vec!["cuprate"]);
}
#[test] #[test]
fn runtime_host_ports_are_manifest_derived_for_public_apps() { fn runtime_host_ports_are_manifest_derived_for_public_apps() {
assert_eq!(runtime_host_ports("photoprism"), vec![2342]); assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
+15 -4
View File
@@ -168,7 +168,7 @@ pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
/// Read disk usage via `df` for a given path. /// Read disk usage via `df` for a given path.
pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> { pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
let output = tokio::process::Command::new("df") let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size", path]) .args(["--block-size=1", "--output=used,size,avail", path])
.output() .output()
.await .await
.context("Failed to run df")?; .context("Failed to run df")?;
@@ -189,11 +189,22 @@ pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
.ok_or_else(|| anyhow::anyhow!("Missing used"))? .ok_or_else(|| anyhow::anyhow!("Missing used"))?
.parse() .parse()
.context("parse df used")?; .context("parse df used")?;
let total: u64 = parts // Raw `size` includes the filesystem's root-reserved blocks (5% by default
// on ext4 — 92 GiB of this node's 1.8 TiB), which nothing can allocate.
// Reporting it as capacity told the dashboard there were 251 GiB free when
// only 159 GiB were writable. Callers derive free as total - used, so total
// must mean "what can actually be used".
let _size: u64 = parts
.next() .next()
.ok_or_else(|| anyhow::anyhow!("Missing total"))? .ok_or_else(|| anyhow::anyhow!("Missing size"))?
.parse() .parse()
.context("parse df total")?; .context("parse df size")?;
let avail: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
.parse()
.context("parse df avail")?;
let total = used.saturating_add(avail);
Ok((used, total)) Ok((used, total))
} }
+58 -19
View File
@@ -4,9 +4,19 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tracing::{info, warn}; use tracing::{info, warn};
/// Parse df output into (used_bytes, total_bytes, used_percent). /// Parse df output into (used_bytes, usable_total_bytes, used_percent).
/// Expects output from `df --block-size=1 --output=used,size /` which has a header line /// Expects `df --block-size=1 --output=used,size,avail <path>`: a header line
/// followed by a data line with two whitespace-separated numbers. /// followed by used, size and avail.
///
/// `size` is deliberately NOT the denominator. ext4 reserves 5% of the
/// filesystem for root — 92 GiB on archi-dev-box's 1.8 TiB disk — which `size`
/// counts but no ordinary process can ever allocate. Dividing by `size`
/// under-reports usage by about five points: on 2026-08-22 that disk was
/// genuinely 90.8% full (159 GiB usable left) while this returned 86.2%, so the
/// 90% auto-cleanup below had never once fired and ~72 GB of dangling images
/// had accumulated. It also meant the dashboard advertised 251 GiB free when
/// only 159 GiB could actually be written. used/(used+avail) is what `df`
/// itself prints and what the operator can actually spend.
fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> { fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
let data_line = stdout let data_line = stdout
.lines() .lines()
@@ -18,11 +28,19 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
.ok_or_else(|| anyhow::anyhow!("Missing used"))? .ok_or_else(|| anyhow::anyhow!("Missing used"))?
.parse() .parse()
.context("parse df used")?; .context("parse df used")?;
let total: u64 = parts // Parsed to keep the column contract explicit, then intentionally unused —
// see the note above on why raw size is the wrong denominator.
let _size: u64 = parts
.next() .next()
.ok_or_else(|| anyhow::anyhow!("Missing total"))? .ok_or_else(|| anyhow::anyhow!("Missing size"))?
.parse() .parse()
.context("parse df total")?; .context("parse df size")?;
let avail: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
.parse()
.context("parse df avail")?;
let total = used.saturating_add(avail);
let percent = if total > 0 { let percent = if total > 0 {
(used as f64 / total as f64) * 100.0 (used as f64 / total as f64) * 100.0
@@ -44,7 +62,7 @@ pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
"/" "/"
}; };
let output = tokio::process::Command::new("df") let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size", data_path]) .args(["--block-size=1", "--output=used,size,avail", data_path])
.output() .output()
.await .await
.context("Failed to run df")?; .context("Failed to run df")?;
@@ -257,8 +275,8 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_normal() { fn test_parse_df_output_normal() {
// Simulates typical df --block-size=1 --output=used,size / output // df --block-size=1 --output=used,size,avail : used, size, avail
let output = " Used Size\n 500000000000 1000000000000\n"; let output = " Used Size Avail\n 500000000000 1000000000000 500000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 500_000_000_000); assert_eq!(used, 500_000_000_000);
assert_eq!(total, 1_000_000_000_000); assert_eq!(total, 1_000_000_000_000);
@@ -267,16 +285,35 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_high_usage() { fn test_parse_df_output_high_usage() {
let output = " Used Size\n 900000000000 1000000000000\n"; let output = " Used Size Avail\n 900000000000 1000000000000 100000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 900_000_000_000); assert_eq!(used, 900_000_000_000);
assert_eq!(total, 1_000_000_000_000); assert_eq!(total, 1_000_000_000_000);
assert!((percent - 90.0).abs() < 0.01); assert!((percent - 90.0).abs() < 0.01);
} }
/// The bug this function existed to hide: reserved blocks are counted by
/// `size` but are not available to anyone. Real numbers from archi-dev-box,
/// 2026-08-22 — 1.8 TiB disk, ext4 5% reserve, genuinely 90.8% full. The old
/// used/size math returned 86.2%, so the 90% auto-cleanup never triggered.
#[test]
fn reserved_blocks_are_not_counted_as_free() {
let output = "Used Size Avail\n1681459122176 1951249276928 170581372928\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 1_681_459_122_176);
// Total is what can actually be written, not the raw device size.
assert_eq!(total, 1_852_040_495_104);
assert!(
total < 1_951_249_276_928,
"raw size must not be the denominator"
);
assert!((percent - 90.8).abs() < 0.1, "got {percent}");
assert!(percent >= 90.0, "must cross the auto-cleanup threshold");
}
#[test] #[test]
fn test_parse_df_output_almost_full() { fn test_parse_df_output_almost_full() {
let output = "Used Size\n999 1000\n"; let output = "Used Size Avail\n999 1000 1\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 999); assert_eq!(used, 999);
assert_eq!(total, 1000); assert_eq!(total, 1000);
@@ -285,7 +322,7 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_empty_disk() { fn test_parse_df_output_empty_disk() {
let output = "Used Size\n0 1000000000000\n"; let output = "Used Size Avail\n0 1000000000000 1000000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 0); assert_eq!(used, 0);
assert_eq!(total, 1_000_000_000_000); assert_eq!(total, 1_000_000_000_000);
@@ -295,7 +332,7 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_zero_total() { fn test_parse_df_output_zero_total() {
// Edge case: total is 0 (should not happen but should not panic/divide-by-zero) // Edge case: total is 0 (should not happen but should not panic/divide-by-zero)
let output = "Used Size\n0 0\n"; let output = "Used Size Avail\n0 0 0\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 0); assert_eq!(used, 0);
assert_eq!(total, 0); assert_eq!(total, 0);
@@ -338,21 +375,23 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_extra_whitespace() { fn test_parse_df_output_extra_whitespace() {
let output = " Used Size \n 123456 7890000 \n"; let output = " Used Size Avail \n 123456 7890000 7766544 \n";
let (used, total, _) = parse_df_output(output).unwrap(); let (used, total, _) = parse_df_output(output).unwrap();
assert_eq!(used, 123456); assert_eq!(used, 123456);
assert_eq!(total, 7890000); assert_eq!(total, 7_890_000);
} }
#[test] #[test]
fn test_parse_df_output_real_world_format() { fn test_parse_df_output_real_world_format() {
// Closer to real df output with header padding // Closer to real df output with header padding
let output = " Used Size\n 328000000000 1800000000000\n"; // Real df output carries a reserved-block gap: size here is 1.8 TB but
// only 1.382 TB is available, so usable total is used + avail.
let output = " Used Size Avail\n 328000000000 1800000000000 1382000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 328_000_000_000); assert_eq!(used, 328_000_000_000);
assert_eq!(total, 1_800_000_000_000); assert_eq!(total, 1_710_000_000_000);
// ~18.2% // ~19.2% against usable space, not 18.2% against the raw device.
assert!(percent > 18.0 && percent < 19.0); assert!(percent > 19.0 && percent < 20.0, "got {percent}");
} }
#[tokio::test] #[tokio::test]
+1 -10
View File
@@ -1746,15 +1746,6 @@ app:
} }
} }
exempt.sort(); exempt.sort();
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
// upstream's own safe-for-public
// subset that wallets connect to directly as a "remote node" over
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
// cuprate's unrestricted RPC (full node control) stays loopback-only
// (auth: local), not in this set.
//
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a // 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
// loopback-only JSON API whose own generated http password // loopback-only JSON API whose own generated http password
// authenticates every request (added with the phoenixd onboarding, // authenticates every request (added with the phoenixd onboarding,
@@ -1771,7 +1762,7 @@ app:
// stage timed out that cycle, so the count here lagged at 17. // stage timed out that cycle, so the count here lagged at 17.
assert_eq!( assert_eq!(
exempt.len(), exempt.len(),
28, 26,
"unauthenticated port set changed — review before updating this count: {exempt:?}" "unauthenticated port set changed — review before updating this count: {exempt:?}"
); );
} }
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,150 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick } from 'vue'
// Controllable doubles shared between the hoisted block and the mock
// factories. Plain holders — each test writes to them before importing the
// composable under a fresh module registry (the watcher keeps a module-level
// `firedThisSession` session guard, so every case needs its own module).
const state = vi.hoisted(() => ({
packages: {} as Record<string, unknown>,
goalStatus: 'in-progress',
goalProgress: {} as Record<string, { completedSteps: string[] }>,
toastAction: vi.fn(),
routerPush: vi.fn(),
// Re-bound every time the useBitcoinSync factory is (re)evaluated; holds the
// exact refs the freshly imported composable watches.
syncRefs: null as null | { synced: { value: boolean }; loaded: { value: boolean } },
}))
vi.mock('@/composables/useBitcoinSync', async () => {
const { ref } = await import('vue')
const synced = ref(false)
const loaded = ref(false)
state.syncRefs = { synced, loaded }
return {
bitcoinSynced: synced,
bitcoinSyncLoaded: loaded,
acquireBitcoinSync: () => () => {},
}
})
vi.mock('@/stores/goals', () => ({
useGoalStore: () => ({
getGoalStatus: () => state.goalStatus,
progress: state.goalProgress,
}),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
get packages() {
return state.packages
},
}),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ action: state.toastAction }),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: state.routerPush }),
}))
/**
* Fresh module registry → fresh `firedThisSession`, then mount the composable
* inside a real component so its watchers live in a proper effect scope.
*/
async function mountWatcher() {
vi.resetModules()
const { useIbdFinishWatcher } = await import('../useIbdFinishWatcher')
const Host = defineComponent({
setup() {
useIbdFinishWatcher()
return () => null
},
})
return mount(Host)
}
/** Drive a real unsynced→synced transition through the mocked sync refs. */
async function completeSync() {
const refs = state.syncRefs!
refs.loaded.value = true
refs.synced.value = false // the watcher must observe unsynced at least once
await nextTick()
refs.synced.value = true
await nextTick()
await nextTick()
}
describe('useIbdFinishWatcher', () => {
beforeEach(() => {
state.packages = {}
state.goalStatus = 'in-progress'
state.goalProgress = {}
state.toastAction.mockClear()
state.routerPush.mockClear()
})
it('says to install LND next when Lightning is not installed yet (#143)', async () => {
// Bitcoin synced mid-goal, but the goal's install-LND step is still
// pending: the on-chain wallet lives in LND, so "fund your wallet" would
// promise a flow that cannot work yet.
state.packages = { 'bitcoin-knots': { state: 'running' } }
const wrapper = await mountWatcher()
await completeSync()
expect(state.toastAction).toHaveBeenCalledTimes(1)
const [message, opts] = state.toastAction.mock.calls[0]
expect(message).toBe(
"Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
)
expect(opts.label).toBe('Finish setup')
opts.onClick()
// "Finish setup" lands on the goal wizard, whose active step is the
// pending install-LND one — the correct next action.
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
wrapper.unmount()
})
it('says to fund the wallet when LND is already installed', async () => {
state.packages = { 'bitcoin-knots': { state: 'running' }, lnd: { state: 'running' } }
const wrapper = await mountWatcher()
await completeSync()
expect(state.toastAction).toHaveBeenCalledTimes(1)
const [message, opts] = state.toastAction.mock.calls[0]
expect(message).toBe(
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
)
expect(opts.label).toBe('Finish setup')
opts.onClick()
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
wrapper.unmount()
})
it('stays silent when no Lightning goal is in progress', async () => {
state.goalStatus = 'not-started'
const wrapper = await mountWatcher()
await completeSync()
expect(state.toastAction).not.toHaveBeenCalled()
wrapper.unmount()
})
it('stays silent when the chain was already synced at page load', async () => {
// A node that's already synced never shows unsynced this session, so the
// toast must not fire (it only marks real IBD-completion transitions).
const wrapper = await mountWatcher()
const refs = state.syncRefs!
refs.loaded.value = true
refs.synced.value = true
await nextTick()
await nextTick()
expect(state.toastAction).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -2,6 +2,7 @@ import { computed, watch, watchEffect, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { GOALS } from '@/data/goals' import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals' import { useGoalStore } from '@/stores/goals'
import { useAppStore } from '@/stores/app'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { import {
acquireBitcoinSync, acquireBitcoinSync,
@@ -20,6 +21,7 @@ let firedThisSession = false
*/ */
export function useIbdFinishWatcher() { export function useIbdFinishWatcher() {
const goalStore = useGoalStore() const goalStore = useGoalStore()
const appStore = useAppStore()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -68,8 +70,16 @@ export function useIbdFinishWatcher() {
const goalId = pendingLightningGoalId.value const goalId = pendingLightningGoalId.value
if (!goalId) return if (!goalId) return
firedThisSession = true firedThisSession = true
// The on-chain wallet lives in LND, not Bitcoin Core — the address the
// fund flow shows comes from `lnd.newaddress`. While the goal's
// install-LND step is still pending, "fund your wallet" would point at
// something that doesn't exist yet, so the toast names the actual next
// step instead (issue #143).
const lndInstalled = Object.keys(appStore.packages).includes('lnd')
toast.action( toast.action(
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.', lndInstalled
? 'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.'
: "Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
{ {
label: 'Finish setup', label: 'Finish setup',
onClick: () => { router.push(`/dashboard/goals/${goalId}`) }, onClick: () => { router.push(`/dashboard/goals/${goalId}`) },